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,
}
}