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

3
assets/icons/archive.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 5.25h14M4.25 5.25v10.5A1.25 1.25 0 0 0 5.5 17h9a1.25 1.25 0 0 0 1.25-1.25V5.25M8 9h4"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

3
assets/icons/pin.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.25 2.75 17.25 7.75M13.5 6.5 8.75 8.25 4.5 12.5l3 3 4.25-4.25 1.75-4.75M6.75 13.25 3 17"/>
</svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@@ -0,0 +1 @@
ALTER TABLE sessions DROP COLUMN state;

View File

@@ -0,0 +1,2 @@
ALTER TABLE sessions ADD COLUMN state TEXT NOT NULL DEFAULT 'normal'
CHECK (state IN ('normal', 'pinned', 'archived'));

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 {

View File

@@ -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::<Vec<_>>();
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<String> {
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(),
})
}

View File

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

View File

@@ -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::<Vec<_>>();
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<Element<'a, Message>>) -> 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)

View File

@@ -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);

View File

@@ -266,6 +266,65 @@ pub struct Session {
pub context_used: i32,
pub context_limit: i32,
pub last_tokens_per_second: Option<f32>,
/// 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<Self> {
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::<Vec<_>>(),
[
(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());

View File

@@ -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<ActiveGeneration, String> {
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<Command>, metrics: Arc<Metrics>) {
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<Command>, metrics: Arc<Metrics>) {
&mut emit,
&mut progress,
),
CheckpointTarget::Transient(directory) => generator.generate_transient(
CheckpointTarget::Transient(directory)
| CheckpointTarget::OneShot(directory) => generator.generate_transient(
&directory,
&command.messages,
&command.turn,

View File

@@ -65,6 +65,7 @@ diesel::table! {
context_used -> Integer,
context_limit -> Integer,
last_tokens_per_second -> Nullable<Float>,
state -> Text,
}
}