diff --git a/migrations/20260727190000_add_session_last_used/down.sql b/migrations/20260727190000_add_session_last_used/down.sql new file mode 100644 index 0000000..07d5fd7 --- /dev/null +++ b/migrations/20260727190000_add_session_last_used/down.sql @@ -0,0 +1 @@ +ALTER TABLE sessions DROP COLUMN last_used; diff --git a/migrations/20260727190000_add_session_last_used/up.sql b/migrations/20260727190000_add_session_last_used/up.sql new file mode 100644 index 0000000..6950640 --- /dev/null +++ b/migrations/20260727190000_add_session_last_used/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE sessions ADD COLUMN last_used BIGINT NOT NULL DEFAULT 0 CHECK (last_used >= 0); + +UPDATE sessions +SET last_used = unixepoch(); diff --git a/src/app.rs b/src/app.rs index acc8f1f..c33bedb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1481,6 +1481,10 @@ impl App { }); match loaded { Ok((messages, a2ui)) => { + if let Err(error) = database.touch_session(session_id) { + self.error = Some(format!("Could not update the session: {error}")); + return Task::none(); + } self.conversation = messages.into_iter().map(ChatMessage::from).collect(); generation::promote_legacy_turn_summaries(&mut self.conversation); self.context_notice = None; @@ -1514,6 +1518,7 @@ impl App { }; self.tokens_per_second = tokens_per_second; self.error = restore_error; + self.reload_projects(); return Task::batch([scroll_chat_to_end(), self.load_next_a2ui_image()]); } Err(error) => { diff --git a/src/database.rs b/src/database.rs index 4e171a2..4483e75 100644 --- a/src/database.rs +++ b/src/database.rs @@ -3,6 +3,7 @@ use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use std::collections::HashMap; use std::fs; use std::path::Path; +use time::OffsetDateTime; use crate::schema::{a2ui_messages, messages, projects, sessions}; @@ -40,6 +41,7 @@ pub struct Session { /// Raw column value; read it through [`Session::state`]. state: String, pub compacted_summary: Option, + last_used: i64, } impl Session { @@ -58,6 +60,7 @@ impl Session { last_tokens_per_second: None, state: state.as_id().to_owned(), compacted_summary: None, + last_used: 0, } } } @@ -105,6 +108,7 @@ impl SessionState { struct NewSession<'a> { project_id: i32, title: &'a str, + last_used: i64, } #[derive(Clone, Debug, Identifiable, Queryable, Selectable)] @@ -207,10 +211,15 @@ 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. + // Preserve sidebar groups while matching DS4's newest-used-first order. for sessions in sessions_by_project.values_mut() { - sessions.sort_by_key(|session| session.state().rank()); + sessions.sort_by(|left, right| { + left.state() + .rank() + .cmp(&right.state().rank()) + .then_with(|| right.last_used.cmp(&left.last_used)) + .then_with(|| right.id.cmp(&left.id)) + }); } Ok(project_rows @@ -275,12 +284,20 @@ impl Database { pub fn create_session(&mut self, project_id: i32, title: &str) -> Result { diesel::insert_into(sessions::table) - .values(NewSession { project_id, title }) + .values(NewSession { + project_id, + title, + last_used: OffsetDateTime::now_utc().unix_timestamp(), + }) .returning(Session::as_returning()) .get_result(&mut self.connection) .map_err(|error| error.to_string()) } + pub fn touch_session(&mut self, session_id: i32) -> Result<(), String> { + touch_session(&mut self.connection, session_id).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() { @@ -431,6 +448,7 @@ impl Database { ) -> Result, String> { self.connection .transaction(|connection| { + touch_session(connection, session_id)?; let mut stored = Vec::with_capacity(system_messages.len() + 2); for content in system_messages { stored.push( @@ -497,6 +515,7 @@ impl Database { ) -> Result, String> { self.connection .transaction(|connection| { + touch_session(connection, session_id)?; let mut stored = Vec::with_capacity(system_messages.len() + 3); stored.push( diesel::insert_into(messages::table) @@ -686,6 +705,16 @@ impl Database { } } +fn touch_session( + connection: &mut SqliteConnection, + session_id: i32, +) -> Result<(), diesel::result::Error> { + diesel::update(sessions::table.find(session_id)) + .set(sessions::last_used.eq(OffsetDateTime::now_utc().unix_timestamp())) + .execute(connection) + .map(|_| ()) +} + #[cfg(test)] mod tests { use super::*; @@ -758,6 +787,46 @@ mod tests { fs::remove_file(path).unwrap(); } + #[test] + fn sessions_are_ordered_by_recent_use_within_sidebar_groups() { + let id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("ds4-session-order-{id}.sqlite3")); + let mut database = Database::open(&path).unwrap(); + let project = database + .create_project("DS4", "/tmp/ds4-session-order") + .unwrap(); + let older = database.create_session(project.id, "Older").unwrap(); + let newer = database.create_session(project.id, "Newer").unwrap(); + let pinned = database.create_session(project.id, "Pinned").unwrap(); + database + .set_session_state(pinned.id, SessionState::Pinned) + .unwrap(); + diesel::update(sessions::table.find(older.id)) + .set(sessions::last_used.eq(100_i64)) + .execute(&mut database.connection) + .unwrap(); + diesel::update(sessions::table.find(newer.id)) + .set(sessions::last_used.eq(200_i64)) + .execute(&mut database.connection) + .unwrap(); + + let loaded = database.load_projects().unwrap(); + assert_eq!( + loaded[0] + .sessions + .iter() + .map(|session| session.id) + .collect::>(), + [pinned.id, newer.id, older.id] + ); + + drop(database); + fs::remove_file(path).unwrap(); + } + #[test] fn a2ui_dismissal_persists_a_fresh_surface_boundary() { let id = SystemTime::now() diff --git a/src/schema.rs b/src/schema.rs index 9eac92c..2bdd815 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -47,6 +47,7 @@ diesel::table! { last_tokens_per_second -> Nullable, state -> Text, compacted_summary -> Nullable, + last_used -> BigInt, } }