diff --git a/assets/icons/archive.svg b/assets/icons/archive.svg new file mode 100644 index 0000000..316aa1f --- /dev/null +++ b/assets/icons/archive.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/pin.svg b/assets/icons/pin.svg new file mode 100644 index 0000000..0abca44 --- /dev/null +++ b/assets/icons/pin.svg @@ -0,0 +1,3 @@ + + + diff --git a/migrations/20260725120000_add_session_state/down.sql b/migrations/20260725120000_add_session_state/down.sql new file mode 100644 index 0000000..8ac44b5 --- /dev/null +++ b/migrations/20260725120000_add_session_state/down.sql @@ -0,0 +1 @@ +ALTER TABLE sessions DROP COLUMN state; diff --git a/migrations/20260725120000_add_session_state/up.sql b/migrations/20260725120000_add_session_state/up.sql new file mode 100644 index 0000000..4121bfb --- /dev/null +++ b/migrations/20260725120000_add_session_state/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE sessions ADD COLUMN state TEXT NOT NULL DEFAULT 'normal' + CHECK (state IN ('normal', 'pinned', 'archived')); diff --git a/src/app.rs b/src/app.rs index 7fba409..bc38f0e 100644 --- a/src/app.rs +++ b/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, + /// Session whose quick-actions menu is open. + session_menu: Option, + /// 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, choosing_folder: bool, pending_project_path: Option, project_name_input: String, @@ -78,6 +84,8 @@ pub(crate) struct App { #[cfg(target_os = "macos")] active_generation: Option, #[cfg(target_os = "macos")] + active_titling: Option, + #[cfg(target_os = "macos")] runtime_preferences: Arc>, #[cfg(target_os = "macos")] _endpoint: Option, @@ -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 { diff --git a/src/app/generation.rs b/src/app/generation.rs index 09317f6..8b40e56 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -1,5 +1,20 @@ use super::*; +/// Asks for a title short enough to fit a sidebar row. +const TITLE_INSTRUCTION: &str = "Give this conversation a short title of at most six words. \ +Reply with the title alone: no quotes, no trailing period, no explanation."; +const TITLE_MAX_TOKENS: i32 = 48; +const TITLE_MAX_CHARS: usize = 60; + +/// A one-shot generation that produces a session title. It runs against the +/// transient KV cache, so it never creates or touches a stored session. +#[cfg(target_os = "macos")] +pub(super) struct TitleRequest { + session_id: i32, + active: ActiveGeneration, + content: String, +} + #[derive(Clone, Debug)] pub(crate) struct ChatMessage { pub(super) id: i32, @@ -262,4 +277,162 @@ impl App { #[cfg(not(target_os = "macos"))] false } + + /// Asks the model for a session title without opening a session for it: the + /// turn runs against the shared transient KV cache and only its text is kept. + #[cfg(target_os = "macos")] + pub(super) fn request_title(&mut self, session_id: i32) { + if self.active_titling.is_some() { + self.error = Some("A title is already being generated.".into()); + return; + } + let model = match ModelChoice::from_id(&self.preferences.selected_model) { + Some(model) => model, + None => { + self.error = Some("The selected model is not supported.".into()); + return; + } + }; + let effective = self.preferences.generation().and_then(|generation| { + self.preferences.runtime().and_then(|runtime| { + crate::settings::effective_settings(model, &generation, &runtime, &models_path()) + }) + }); + let mut effective = match effective { + Ok(settings) => settings, + Err(error) => { + self.error = Some(error); + return; + } + }; + let Some(database) = &mut self.database else { + return; + }; + let stored = match database.load_messages(session_id) { + Ok(stored) => stored, + Err(error) => { + self.error = Some(format!("Could not read the session: {error}")); + return; + } + }; + if stored.is_empty() { + self.error = Some("This session has no messages to summarize yet.".into()); + return; + } + let mut messages = stored + .into_iter() + .map(|message| ChatTurn { + user: message.user, + skip_previous_eos: false, + reasoning: None, + reasoning_complete: true, + content: message.content, + }) + .collect::>(); + messages.push(ChatTurn { + user: true, + skip_previous_eos: false, + reasoning: None, + reasoning_complete: true, + content: TITLE_INSTRUCTION.to_owned(), + }); + effective.turn.max_generated_tokens = TITLE_MAX_TOKENS; + effective.turn.reasoning_mode = ReasoningMode::Direct; + + let Some(service) = &self.generation_service else { + self.error = Some("The model runtime is unavailable.".into()); + return; + }; + let idle_timeout = + Duration::from_secs(self.preferences.idle_timeout_minutes.max(1) as u64 * 60); + match service.generate( + effective.engine, + effective.turn, + messages, + CheckpointTarget::OneShot(transient_cache_path()), + idle_timeout, + ) { + Ok(active) => { + self.active_titling = Some(TitleRequest { + session_id, + active, + content: String::new(), + }); + self.error = None; + } + Err(error) => { + self.generation_service = None; + self.error = Some(error); + } + } + } + + /// Drains the one-shot title generation. Returns true while it is running so + /// the caller keeps ticking. + #[cfg(target_os = "macos")] + pub(super) fn poll_titling(&mut self) -> bool { + let Some(mut request) = self.active_titling.take() else { + return false; + }; + let failure = loop { + match request.active.events.try_recv() { + Ok(GenerationEvent::Chunk { + reasoning: false, + content, + }) => request.content.push_str(&content), + Ok(GenerationEvent::Finished(Err(error))) => break Some(error), + Ok(GenerationEvent::Finished(Ok(_))) => break None, + Ok(_) => {} + Err(TryRecvError::Empty) => { + self.active_titling = Some(request); + return true; + } + Err(TryRecvError::Disconnected) => { + break Some("The model runtime stopped unexpectedly.".to_owned()); + } + } + }; + if let Some(error) = failure { + self.error = Some(format!("Could not generate a title: {error}")); + } else if let Some(title) = session_title(&request.content) { + self.apply_title(request.session_id, &title); + } else { + self.error = Some("The model did not return a usable title.".into()); + } + false + } + + #[cfg(target_os = "macos")] + fn apply_title(&mut self, session_id: i32, title: &str) { + let Some(database) = &mut self.database else { + return; + }; + match database.rename_session(session_id, title) { + Ok(()) => { + self.error = None; + self.reload_projects(); + } + Err(error) => self.error = Some(format!("Could not save the session title: {error}")), + } + } +} + +/// Reduces a model reply to a single sidebar-sized line, or `None` if nothing +/// usable came back. +pub(super) fn session_title(reply: &str) -> Option { + let title = reply + .lines() + .map(str::trim) + .find(|line| !line.is_empty())? + .trim_matches(|character: char| { + character.is_whitespace() || matches!(character, '"' | '\'' | '`' | '*' | '#' | '.') + }) + .trim(); + if title.is_empty() { + return None; + } + Some(match title.char_indices().nth(TITLE_MAX_CHARS) { + Some((index, _)) => format!("{}…", title[..index].trim_end()), + None => title.to_owned(), + }) } diff --git a/src/app/projects.rs b/src/app/projects.rs index 5b976cf..0bd932e 100644 --- a/src/app/projects.rs +++ b/src/app/projects.rs @@ -138,6 +138,27 @@ mod tests { use super::*; use crate::database::{Project, Session}; + #[test] + fn session_states_are_exclusive_and_ordered() { + assert_eq!(SessionState::from_id("pinned"), Some(SessionState::Pinned)); + assert_eq!(SessionState::from_id("nonsense"), None); + assert_eq!(SessionState::default(), SessionState::Normal); + let mut ranks = [ + SessionState::Archived, + SessionState::Normal, + SessionState::Pinned, + ]; + ranks.sort_by_key(|state| state.rank()); + assert_eq!( + ranks, + [ + SessionState::Pinned, + SessionState::Normal, + SessionState::Archived + ] + ); + } + #[test] fn draft_titles_follow_the_stored_session_count() { let projects = vec![ @@ -164,13 +185,11 @@ mod tests { } fn session(id: i32, project_id: i32) -> Session { - Session { + Session::fixture( id, project_id, - title: format!("Session {id}"), - context_used: 0, - context_limit: 0, - last_tokens_per_second: None, - } + &format!("Session {id}"), + SessionState::Normal, + ) } } diff --git a/src/app/view.rs b/src/app/view.rs index 44682f1..8af98fa 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -9,7 +9,7 @@ use super::{ ActiveDownload, App, DetailTab, Message, MetricsPoint, ModelDownload, ModelOperation, chat_scroll_id, models_path, }; -use crate::database::{ProjectWithSessions, Session}; +use crate::database::{ProjectWithSessions, Session, SessionState}; use crate::model::{ self, DownloadPhase, MODEL_CHOICES, ManagedArtifact, ManagedArtifactState, ModelChoice, }; @@ -34,6 +34,8 @@ const ICON_PAPERCLIP: &[u8] = include_bytes!("../../assets/icons/paperclip.svg") const ICON_SEND: &[u8] = include_bytes!("../../assets/icons/send.svg"); const ICON_MODEL: &[u8] = include_bytes!("../../assets/icons/model.svg"); const ICON_SPARK: &[u8] = include_bytes!("../../assets/icons/spark.svg"); +const ICON_PIN: &[u8] = include_bytes!("../../assets/icons/pin.svg"); +const ICON_ARCHIVE: &[u8] = include_bytes!("../../assets/icons/archive.svg"); impl App { pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> { @@ -72,6 +74,10 @@ impl App { layers.push(self.preferences_panel()); } else if let Some(path) = &self.pending_project_path { layers.push(self.project_dialog(path)); + } else if let Some((_, title)) = &self.session_rename { + layers.push(self.rename_dialog(title)); + } else if let Some(session) = self.menu_session() { + layers.push(self.session_menu_panel(session)); } stack(layers) @@ -146,30 +152,44 @@ impl App { .align_y(Alignment::Center), ); - for session in &item.sessions { - let session_selected = selected && self.selected_session == Some(session.id); + for session in item + .sessions + .iter() + .filter(|session| session.state() != SessionState::Archived) + { + projects = projects.push(self.session_row(project.id, session, selected)); + } + + let archived = item + .sessions + .iter() + .filter(|session| session.state() == SessionState::Archived) + .collect::>(); + if !archived.is_empty() { + let expanded = self.expanded_archives.contains(&project.id); projects = projects.push( row![ Space::with_width(26), button( - row![icon(ICON_CHAT, 15), text(&session.title).size(13)] - .spacing(8) - .align_y(Alignment::Center), + text(format!( + "{} Archived sessions ({})", + if expanded { "▾" } else { "›" }, + archived.len() + )) + .size(12) + .color(muted_text()), ) .width(Length::Fill) - .on_press(Message::SelectSession(project.id, session.id)) - .style(if session_selected { - button::secondary - } else { - button::text - }), - button(icon(ICON_TRASH, 14)) - .on_press(Message::DeleteSession(session.id)) - .style(button::text), + .on_press(Message::ToggleArchivedSessions(project.id)) + .style(button::text), ] - .spacing(3) .align_y(Alignment::Center), ); + if expanded { + for session in archived { + projects = projects.push(self.session_row(project.id, session, selected)); + } + } } if let Some(title) = self.drafts.get(&project.id) { @@ -217,6 +237,42 @@ impl App { .into() } + fn session_row<'a>( + &'a self, + project_id: i32, + session: &'a Session, + project_selected: bool, + ) -> Element<'a, Message> { + let session_selected = project_selected && self.selected_session == Some(session.id); + let mut label = row![icon(ICON_CHAT, 15)] + .spacing(8) + .align_y(Alignment::Center); + if session.state() == SessionState::Pinned { + label = label.push(icon(ICON_PIN, 12)); + } + label = label.push(text(&session.title).size(13)); + row![ + Space::with_width(26), + button(label) + .width(Length::Fill) + .on_press(Message::SelectSession(project_id, session.id)) + .style(if session_selected { + button::secondary + } else { + button::text + }), + button(icon(ICON_MORE, 14)) + .on_press(Message::OpenSessionMenu(session.id)) + .style(button::text), + button(icon(ICON_TRASH, 14)) + .on_press(Message::DeleteSession(session.id)) + .style(button::text), + ] + .spacing(3) + .align_y(Alignment::Center) + .into() + } + fn detail(&self) -> Element<'_, Message> { let chat_active = self.detail_tab == DetailTab::Chat; let stats_active = self.detail_tab == DetailTab::Stats; @@ -287,6 +343,110 @@ impl App { ) } + fn menu_session(&self) -> Option<&Session> { + let session_id = self.session_menu?; + self.projects + .iter() + .flat_map(|project| &project.sessions) + .find(|session| session.id == session_id) + } + + /// Quick actions for one session. Which lifecycle moves are offered depends + /// on the state the session is in. + fn session_menu_panel<'a>(&self, session: &'a Session) -> Element<'a, Message> { + let state = session.state(); + let mut actions = column![ + menu_action(ICON_NEW_SESSION, "Rename session") + .on_press(Message::StartRenameSession(session.id)), + menu_action(ICON_SPARK, "Retitle with AI") + .on_press(Message::RetitleSession(session.id)), + ] + .spacing(4); + actions = match state { + SessionState::Normal => actions.push( + menu_action(ICON_PIN, "Pin session") + .on_press(Message::SetSessionState(session.id, SessionState::Pinned)), + ), + SessionState::Pinned => actions.push( + menu_action(ICON_PIN, "Unpin session") + .on_press(Message::SetSessionState(session.id, SessionState::Normal)), + ), + SessionState::Archived => actions, + }; + actions = match state { + SessionState::Archived => actions.push( + menu_action(ICON_ARCHIVE, "Unarchive session") + .on_press(Message::SetSessionState(session.id, SessionState::Normal)), + ), + _ => actions.push( + menu_action(ICON_ARCHIVE, "Archive session") + .on_press(Message::SetSessionState(session.id, SessionState::Archived)), + ), + }; + + let dialog = container( + column![ + text(&session.title).size(16), + text(match state { + SessionState::Normal => "Session", + SessionState::Pinned => "Pinned session", + SessionState::Archived => "Archived session", + }) + .size(12) + .color(muted_text()), + actions, + row![ + Space::with_width(Length::Fill), + action_button("Close").on_press(Message::DismissPanel), + ], + ] + .spacing(12), + ) + .padding(20) + .width(320) + .style(overview_style); + + opaque( + container(dialog) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(|_| { + container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68)) + }), + ) + } + + fn rename_dialog<'a>(&self, title: &'a str) -> Element<'a, Message> { + let dialog = container( + column![ + text("Rename session").size(24), + text_input("Session title", title) + .on_input(Message::SessionTitleChanged) + .on_submit(Message::ConfirmRenameSession) + .padding(10), + row![ + Space::with_width(Length::Fill), + action_button("Cancel").on_press(Message::DismissPanel), + action_button("Rename").on_press(Message::ConfirmRenameSession), + ] + .spacing(8), + ] + .spacing(12), + ) + .padding(22) + .width(440) + .style(overview_style); + + opaque( + container(dialog) + .center_x(Length::Fill) + .center_y(Length::Fill) + .style(|_| { + container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68)) + }), + ) + } + pub(super) fn selected_project(&self) -> Option<&ProjectWithSessions> { self.projects .iter() @@ -360,6 +520,17 @@ fn action_button<'a>(content: impl Into>) -> Button<'a, Mes button(content).padding([8, 14]).style(action_button_style) } +fn menu_action(glyph: &'static [u8], label: &'static str) -> Button<'static, Message> { + button( + row![icon(glyph, 15), text(label).size(13)] + .spacing(9) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .padding([8, 10]) + .style(button::text) +} + fn metric_card(label: &'static str, value: String, detail: String) -> Element<'static, Message> { container( column![ @@ -581,7 +752,9 @@ fn sidebar_style(_: &Theme) -> container::Style { container::Style::default().background(Color::from_rgb8(23, 23, 25)) } -fn icon(data: &'static [u8], size: u16) -> Svg<'static> { +// The lifetime is free: the handle owns its bytes and the style closure is +// 'static, so an icon can join a row that borrows shorter-lived data. +fn icon<'a>(data: &'static [u8], size: u16) -> Svg<'a> { svg(svg::Handle::from_memory(data)) .width(size as f32) .height(size as f32) diff --git a/src/app/view/chat.rs b/src/app/view/chat.rs index af746f1..fd27db1 100644 --- a/src/app/view/chat.rs +++ b/src/app/view/chat.rs @@ -31,14 +31,18 @@ impl App { let project = &item.project; let active_title = self.active_session_title(item); let selected_title = active_title.unwrap_or(project.name.as_str()); - let header = row![ - icon(ICON_FOLDER, 19), - text(selected_title).size(18), - icon(ICON_MORE, 18), - Space::with_width(Length::Fill), - ] - .spacing(10) - .align_y(Alignment::Center); + let mut header = row![icon(ICON_FOLDER, 19), text(selected_title).size(18)]; + if let Some(session) = self.selected_session(item) { + header = header.push( + button(icon(ICON_MORE, 18)) + .on_press(Message::OpenSessionMenu(session.id)) + .style(button::text), + ); + } + let header = header + .push(Space::with_width(Length::Fill)) + .spacing(10) + .align_y(Alignment::Center); let body: Element<'_, Message> = if let Some(title) = active_title { let mut messages = column![].spacing(12); diff --git a/src/database.rs b/src/database.rs index 89c60de..9dabd2c 100644 --- a/src/database.rs +++ b/src/database.rs @@ -266,6 +266,65 @@ pub struct Session { pub context_used: i32, pub context_limit: i32, pub last_tokens_per_second: Option, + /// Raw column value; read it through [`Session::state`]. + state: String, +} + +impl Session { + pub fn state(&self) -> SessionState { + SessionState::from_id(&self.state).unwrap_or_default() + } + + #[cfg(test)] + pub fn fixture(id: i32, project_id: i32, title: &str, state: SessionState) -> Self { + Self { + id, + project_id, + title: title.to_owned(), + context_used: 0, + context_limit: 0, + last_tokens_per_second: None, + state: state.as_id().to_owned(), + } + } +} + +/// Where a session sits in the sidebar. These are lifecycle states, not flags: +/// a session is exactly one of them. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum SessionState { + #[default] + Normal, + Pinned, + Archived, +} + +impl SessionState { + pub fn as_id(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Pinned => "pinned", + Self::Archived => "archived", + } + } + + pub fn from_id(id: &str) -> Option { + match id { + "normal" => Some(Self::Normal), + "pinned" => Some(Self::Pinned), + "archived" => Some(Self::Archived), + _ => None, + } + } + + /// Sidebar grouping order: pinned, then ordinary, then archived. + pub fn rank(self) -> u8 { + match self { + Self::Pinned => 0, + Self::Normal => 1, + Self::Archived => 2, + } + } } #[derive(Insertable)] @@ -341,6 +400,11 @@ impl Database { .or_default() .push(session); } + // Pinned first, then ordinary, then archived; creation order within each + // group. A stable sort keeps the id ordering the query established. + for sessions in sessions_by_project.values_mut() { + sessions.sort_by_key(|session| session.state().rank()); + } Ok(project_rows .into_iter() @@ -467,6 +531,30 @@ impl Database { .map_err(|error| error.to_string()) } + pub fn rename_session(&mut self, session_id: i32, title: &str) -> Result<(), String> { + let title = title.trim(); + if title.is_empty() { + return Err("Session title cannot be empty.".into()); + } + diesel::update(sessions::table.find(session_id)) + .set(sessions::title.eq(title)) + .execute(&mut self.connection) + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + pub fn set_session_state( + &mut self, + session_id: i32, + state: SessionState, + ) -> Result<(), String> { + diesel::update(sessions::table.find(session_id)) + .set(sessions::state.eq(state.as_id())) + .execute(&mut self.connection) + .map(|_| ()) + .map_err(|error| error.to_string()) + } + pub fn delete_session(&mut self, session_id: i32) -> Result<(), String> { self.connection .transaction(|connection| { @@ -653,6 +741,39 @@ mod tests { database.delete_session(first.id).unwrap(); let loaded = database.load_projects().unwrap(); assert_eq!(loaded[0].sessions[0].title, "Second session"); + assert_eq!(loaded[0].sessions[0].state(), SessionState::Normal); + + let ordinary = loaded[0].sessions[0].id; + let pinned = database.create_session(project.id, "Pinned").unwrap(); + let archived = database.create_session(project.id, "Archived").unwrap(); + database + .set_session_state(pinned.id, SessionState::Pinned) + .unwrap(); + database + .set_session_state(archived.id, SessionState::Archived) + .unwrap(); + database.rename_session(pinned.id, " Renamed ").unwrap(); + assert!(database.rename_session(pinned.id, " ").is_err()); + let loaded = database.load_projects().unwrap(); + assert_eq!( + loaded[0] + .sessions + .iter() + .map(|session| (session.id, session.state())) + .collect::>(), + [ + (pinned.id, SessionState::Pinned), + (ordinary, SessionState::Normal), + (archived.id, SessionState::Archived), + ] + ); + assert_eq!(loaded[0].sessions[0].title, "Renamed"); + // Archiving a pinned session moves it out of the pinned group entirely. + database + .set_session_state(pinned.id, SessionState::Archived) + .unwrap(); + let loaded = database.load_projects().unwrap(); + assert_eq!(loaded[0].sessions[0].id, ordinary); database.delete_project(project.id).unwrap(); assert!(database.load_projects().unwrap().is_empty()); diff --git a/src/runtime.rs b/src/runtime.rs index 7a13dd3..9202bae 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -22,6 +22,18 @@ pub(crate) struct ActiveGeneration { pub(crate) enum CheckpointTarget { Local(PathBuf), Transient(PathBuf), + /// Same transient KV handling as [`CheckpointTarget::Transient`], but asked + /// for by the app itself (session titling) rather than by an HTTP client. + OneShot(PathBuf), +} + +impl CheckpointTarget { + fn source(&self) -> WorkSource { + match self { + Self::Local(_) | Self::OneShot(_) => WorkSource::LocalChat, + Self::Transient(_) => WorkSource::Http, + } + } } pub(crate) enum GenerationEvent { @@ -67,10 +79,7 @@ impl GenerationService { checkpoint: CheckpointTarget, idle_timeout: Duration, ) -> Result { - let source = match &checkpoint { - CheckpointTarget::Local(_) => WorkSource::LocalChat, - CheckpointTarget::Transient(_) => WorkSource::Http, - }; + let source = checkpoint.source(); let cancel = Arc::new(AtomicBool::new(false)); let (events, receiver) = mpsc::channel(); self.metrics.request_queued(source); @@ -105,10 +114,7 @@ fn run(commands: Receiver, metrics: Arc) { match commands.recv_timeout(Duration::from_secs(1)) { Ok(command) => { let request_started = Instant::now(); - let source = match &command.checkpoint { - CheckpointTarget::Local(_) => WorkSource::LocalChat, - CheckpointTarget::Transient(_) => WorkSource::Http, - }; + let source = command.checkpoint.source(); metrics.request_started(source); idle_timeout = command.idle_timeout; if loaded @@ -176,7 +182,8 @@ fn run(commands: Receiver, metrics: Arc) { &mut emit, &mut progress, ), - CheckpointTarget::Transient(directory) => generator.generate_transient( + CheckpointTarget::Transient(directory) + | CheckpointTarget::OneShot(directory) => generator.generate_transient( &directory, &command.messages, &command.turn, diff --git a/src/schema.rs b/src/schema.rs index 972254f..ec0c124 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -65,6 +65,7 @@ diesel::table! { context_used -> Integer, context_limit -> Integer, last_tokens_per_second -> Nullable, + state -> Text, } }