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:
145
src/app.rs
145
src/app.rs
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user