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:
121
src/database.rs
121
src/database.rs
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user