Add quick actions to sessions

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

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

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

View File

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