Add quick actions to sessions

Rename, AI retitle, pin and archive, reached from the "..." button on
every session row and in the chat header. Sessions carry one lifecycle
state (normal, pinned, archived) rather than independent flags, so the
sidebar groups them without an unrepresentable pinned-and-archived case.

The AI retitle runs as a one-shot against the transient KV cache through
a new CheckpointTarget::OneShot, leaving no session or checkpoint behind
while still reporting as local work in the metrics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Georg Bauer
2026-07-25 12:15:58 +02:00
parent cc9e09ff1d
commit 35087580d5
12 changed files with 687 additions and 45 deletions

View File

@@ -12,7 +12,7 @@ use preferences::PreferenceDraft;
#[cfg(test)]
use preferences::{parse_optional_gib, parse_streaming_cache};
use crate::database::{AppPreferences, Database, ProjectWithSessions, StoredMessage};
use crate::database::{AppPreferences, Database, ProjectWithSessions, SessionState, StoredMessage};
#[cfg(target_os = "macos")]
use crate::engine::ChatTurn;
use crate::metrics::{Metrics, MetricsSnapshot};
@@ -27,7 +27,7 @@ use crate::settings::{
use iced::widget::{markdown, scrollable};
use iced::{Size, Subscription, Task, keyboard, window};
use rfd::AsyncFileDialog;
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -58,6 +58,12 @@ pub(crate) struct App {
/// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row
/// when its first chat turn is stored, so empty ones vanish on restart.
drafts: HashMap<i32, String>,
/// Session whose quick-actions menu is open.
session_menu: Option<i32>,
/// Session being renamed, with the in-progress title.
session_rename: Option<(i32, String)>,
/// Projects whose archived sessions are expanded in the sidebar.
expanded_archives: HashSet<i32>,
choosing_folder: bool,
pending_project_path: Option<PathBuf>,
project_name_input: String,
@@ -78,6 +84,8 @@ pub(crate) struct App {
#[cfg(target_os = "macos")]
active_generation: Option<ActiveGeneration>,
#[cfg(target_os = "macos")]
active_titling: Option<generation::TitleRequest>,
#[cfg(target_os = "macos")]
runtime_preferences: Arc<RwLock<AppPreferences>>,
#[cfg(target_os = "macos")]
_endpoint: Option<crate::server::ServerHandle>,
@@ -170,6 +178,13 @@ pub(crate) enum Message {
DiscardSession(i32),
SelectSession(i32, i32),
DeleteSession(i32),
OpenSessionMenu(i32),
StartRenameSession(i32),
SessionTitleChanged(String),
ConfirmRenameSession,
RetitleSession(i32),
SetSessionState(i32, SessionState),
ToggleArchivedSessions(i32),
ShowChat,
ShowStats,
MetricsTick,
@@ -209,6 +224,9 @@ impl App {
selected_project: None,
selected_session: None,
drafts: HashMap::new(),
session_menu: None,
session_rename: None,
expanded_archives: HashSet::new(),
choosing_folder: false,
pending_project_path: None,
project_name_input: String::new(),
@@ -229,6 +247,8 @@ impl App {
#[cfg(target_os = "macos")]
active_generation: None,
#[cfg(target_os = "macos")]
active_titling: None,
#[cfg(target_os = "macos")]
runtime_preferences,
#[cfg(target_os = "macos")]
_endpoint: endpoint,
@@ -281,6 +301,9 @@ impl App {
selected_project: None,
selected_session: None,
drafts: HashMap::new(),
session_menu: None,
session_rename: None,
expanded_archives: HashSet::new(),
choosing_folder: false,
pending_project_path: None,
project_name_input: String::new(),
@@ -301,6 +324,8 @@ impl App {
#[cfg(target_os = "macos")]
active_generation: None,
#[cfg(target_os = "macos")]
active_titling: None,
#[cfg(target_os = "macos")]
runtime_preferences,
#[cfg(target_os = "macos")]
_endpoint: endpoint,
@@ -350,7 +375,10 @@ impl App {
}
}
Message::DismissPanel => {
if self.preferences_open {
if self.session_rename.is_some() || self.session_menu.is_some() {
self.session_rename = None;
self.session_menu = None;
} else if self.preferences_open {
self.preferences_open = false;
self.preference_error = None;
} else if self.pending_project_path.is_some() {
@@ -613,6 +641,8 @@ impl App {
}
}
Message::GenerationTick => {
#[cfg(target_os = "macos")]
self.poll_titling();
if self.poll_generation() {
return scroll_chat_to_end();
}
@@ -698,6 +728,73 @@ impl App {
}
Message::CreateSession(project_id) => self.create_session(project_id),
Message::DiscardSession(project_id) => self.discard_session(project_id),
Message::OpenSessionMenu(session_id) => {
self.session_rename = None;
self.session_menu = if self.session_menu == Some(session_id) {
None
} else {
Some(session_id)
};
}
Message::StartRenameSession(session_id) => {
let title = self
.projects
.iter()
.flat_map(|project| &project.sessions)
.find(|session| session.id == session_id)
.map(|session| session.title.clone())
.unwrap_or_default();
self.session_menu = None;
self.session_rename = Some((session_id, title));
}
Message::SessionTitleChanged(title) => {
if let Some((_, draft)) = &mut self.session_rename {
*draft = title;
}
}
Message::ConfirmRenameSession => {
// Keep the dialog open on failure so the edit is not lost.
let Some((session_id, title)) = self.session_rename.clone() else {
return Task::none();
};
if let Some(database) = &mut self.database {
match database.rename_session(session_id, &title) {
Ok(()) => {
self.session_rename = None;
self.error = None;
self.reload_projects();
}
Err(error) => self.error = Some(error),
}
}
}
Message::RetitleSession(session_id) => {
self.session_menu = None;
#[cfg(target_os = "macos")]
self.request_title(session_id);
#[cfg(not(target_os = "macos"))]
{
let _ = session_id;
self.error = Some("Local Metal generation requires macOS.".into());
}
}
Message::SetSessionState(session_id, state) => {
self.session_menu = None;
if let Some(database) = &mut self.database {
match database.set_session_state(session_id, state) {
Ok(()) => {
self.error = None;
self.reload_projects();
}
Err(error) => self.error = Some(error),
}
}
}
Message::ToggleArchivedSessions(project_id) => {
if !self.expanded_archives.remove(&project_id) {
self.expanded_archives.insert(project_id);
}
}
Message::SelectSession(project_id, session_id) => {
if self.generating && self.selected_session != Some(session_id) {
self.error =
@@ -754,6 +851,16 @@ impl App {
match database.delete_session(session_id) {
Ok(()) => {
let _ = fs::remove_file(session_checkpoint_path(session_id));
if self.session_menu == Some(session_id) {
self.session_menu = None;
}
if self
.session_rename
.as_ref()
.is_some_and(|(id, _)| *id == session_id)
{
self.session_rename = None;
}
if self.selected_session == Some(session_id) {
self.selected_session = None;
self.conversation.clear();
@@ -797,7 +904,11 @@ impl App {
iced::time::every(Duration::from_secs(1)).map(|_| Message::DownloadProgressTick),
);
}
if self.generating {
#[cfg(target_os = "macos")]
let titling = self.active_titling.is_some();
#[cfg(not(target_os = "macos"))]
let titling = false;
if self.generating || titling {
subscriptions.push(
iced::time::every(Duration::from_millis(50)).map(|_| Message::GenerationTick),
);
@@ -863,7 +974,7 @@ fn spawn_services(
generation.clone(),
Arc::clone(&runtime_preferences),
models_path(),
application_support_path().join("kv-cache").join("http"),
transient_cache_path(),
u16::try_from(preferences.endpoint_port).unwrap_or(4000),
metrics,
);
@@ -923,6 +1034,12 @@ fn session_checkpoint_path(session_id: i32) -> PathBuf {
.join(format!("{session_id}.bin"))
}
/// Content-addressed KV cache for turns that belong to no stored session: the
/// HTTP endpoint and the app's own one-shot requests share it.
fn transient_cache_path() -> PathBuf {
application_support_path().join("kv-cache").join("http")
}
pub(crate) fn app_icon() -> window::Icon {
let decoder = png::Decoder::new(std::io::Cursor::new(include_bytes!(
"../assets/app-icon.png"
@@ -976,6 +1093,24 @@ mod tests {
assert_eq!(parse_optional_gib("Memory", "8GB").unwrap(), Some(8));
}
#[test]
fn one_shot_titles_are_reduced_to_a_single_sidebar_line() {
use generation::session_title;
assert_eq!(
session_title("\"Metal kernel debugging\"").as_deref(),
Some("Metal kernel debugging")
);
assert_eq!(
session_title("Fixing the tokenizer.\nThat is my answer.").as_deref(),
Some("Fixing the tokenizer")
);
assert_eq!(session_title(" \n \n").as_deref(), None);
let long = "word ".repeat(40);
let title = session_title(&long).expect("a long reply still yields a title");
assert!(title.chars().count() <= 61, "{title}");
assert!(title.ends_with('…'));
}
#[test]
fn assistant_stream_keeps_reasoning_separate_from_the_answer() {
let mut message = ChatMessage {