diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index d7aa32b86..5a14b7788 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -192,6 +192,10 @@ pub struct NoteTimelineItem { /// `None` for session-produced notes, `"written"` for user-authored ones — /// the frontend routes the latter to the editor instead of the viewer. pub subtype: Option, + /// When the session's chat last got a reply it shows. + pub last_chat_message_at: Option, + /// When the user last read the session's chat (`sessions.chat_read_at`). + pub chat_read_at: Option, } /// Review with session status resolved. @@ -2330,6 +2334,7 @@ pub fn run() { session_commands::get_tool_image, session_commands::get_tool_call_body, session_commands::count_assistant_messages_after, + session_commands::mark_session_chat_read, session_commands::start_session, session_commands::resume_session, session_commands::queue_session_message, diff --git a/apps/staged/src-tauri/src/note_commands.rs b/apps/staged/src-tauri/src/note_commands.rs index cf5eaa62d..1631e21c1 100644 --- a/apps/staged/src-tauri/src/note_commands.rs +++ b/apps/staged/src-tauri/src/note_commands.rs @@ -12,6 +12,13 @@ use std::sync::{Arc, Mutex}; /// session status identically. pub(crate) fn note_to_timeline_item(store: &Store, note: Note) -> NoteTimelineItem { let resolved = store.resolve_session_status(note.session_id.as_deref()); + // Best-effort, like the status above: a lookup miss leaves the chat button + // in its plain state rather than failing the note. + let (last_chat_message_at, chat_read_at) = note + .session_id + .as_deref() + .and_then(|session_id| store.session_chat_activity(session_id).ok()) + .unwrap_or_default(); NoteTimelineItem { id: note.id, title: note.title, @@ -25,6 +32,8 @@ pub(crate) fn note_to_timeline_item(store: &Store, note: Note) -> NoteTimelineIt suggested_next_commit_step: note.suggested_next_commit_step, suggested_next_note_step: note.suggested_next_note_step, subtype: note.subtype, + last_chat_message_at, + chat_read_at, } } @@ -44,6 +53,8 @@ pub(crate) fn standalone_note_to_timeline_item(note: Note) -> NoteTimelineItem { suggested_next_commit_step: None, suggested_next_note_step: None, subtype: note.subtype, + last_chat_message_at: None, + chat_read_at: None, } } diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 22ccc5007..b7e08eb53 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -1278,6 +1278,18 @@ pub fn count_assistant_messages_after( .map_err(|e| e.to_string()) } +/// Record that the user has read the session's chat, clearing the unread dot +/// on its note row. +#[tauri::command] +pub fn mark_session_chat_read( + store: tauri::State<'_, Mutex>>>, + session_id: String, +) -> Result<(), String> { + get_store(&store)? + .mark_session_chat_read(&session_id) + .map_err(|e| e.to_string()) +} + // ============================================================================= // Lifecycle commands // ============================================================================= diff --git a/apps/staged/src-tauri/src/store/messages.rs b/apps/staged/src-tauri/src/store/messages.rs index d503a8938..d8104028a 100644 --- a/apps/staged/src-tauri/src/store/messages.rs +++ b/apps/staged/src-tauri/src/store/messages.rs @@ -13,6 +13,11 @@ const SESSION_MESSAGE_COLUMNS: &str = "id, session_id, role, content, created_at acp_raw_input, acp_raw_output, acp_content, acp_locations, acp_usage, acp_session_info, acp_config_options, acp_session_mode_state, acp_origin"; const VISIBLE_MESSAGE_FILTER: &str = "NOT (content = '' AND acp_event_kind IS NOT NULL)"; +/// Assistant replies the chat shows (empty ACP rows never render): what +/// [`Store::count_assistant_messages_after`] counts and what a note row's chat +/// button compares against. +pub(super) const VISIBLE_ASSISTANT_FILTER: &str = + "role = 'assistant' AND NOT (content = '' AND acp_event_kind IS NOT NULL)"; /// ACP rows the transcript needs on top of [`Store::get_session_messages`]: /// hidden rows of the kinds the frontend reads (`acpTranscript.ts`, /// `acpConfigMetadata.ts`), plus visible tool rows still in flight — the @@ -592,17 +597,37 @@ impl Store { ) -> Result { let conn = self.conn.lock().unwrap(); let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM session_messages - WHERE session_id = ?1 - AND role = 'assistant' - AND created_at > ?2 - AND NOT (content = '' AND acp_event_kind IS NOT NULL)", + &format!( + "SELECT COUNT(*) FROM session_messages + WHERE session_id = ?1 AND created_at > ?2 AND {VISIBLE_ASSISTANT_FILTER}" + ), params![session_id, after_timestamp], |row| row.get(0), )?; Ok(count) } + /// When a session's chat last got a reply it shows, and when the user last + /// read it (`sessions.chat_read_at`): the inputs to a note row's chat + /// button. Either is `None` when there is no such reply, or no read yet. + pub fn session_chat_activity( + &self, + session_id: &str, + ) -> Result<(Option, Option), StoreError> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + &format!( + "SELECT + (SELECT MAX(created_at) FROM session_messages + WHERE session_id = ?1 AND {VISIBLE_ASSISTANT_FILTER}), + (SELECT chat_read_at FROM sessions WHERE id = ?1)" + ), + params![session_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(Into::into) + } + /// Get messages with id >= since_id, plus fresh copies of `refetch_ids`. /// /// The inclusive `>=` re-fetches the last known message so the caller diff --git a/apps/staged/src-tauri/src/store/migration_tests.rs b/apps/staged/src-tauri/src/store/migration_tests.rs index 775b22a05..856016194 100644 --- a/apps/staged/src-tauri/src/store/migration_tests.rs +++ b/apps/staged/src-tauri/src/store/migration_tests.rs @@ -145,7 +145,7 @@ fn test_store_bootstraps_fresh_database_with_baseline_migration() { ) .unwrap(); - assert_eq!(version, 34); + assert_eq!(version, 35); assert_eq!(app_version, super::APP_VERSION); assert!(table_exists(&conn, "projects")); assert!(table_exists(&conn, "project_notes")); @@ -271,7 +271,7 @@ fn test_store_repairs_github_comment_tracking_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 34); + assert_eq!(version, 35); assert!(column_exists(&conn, "sessions", "pipeline")); assert!(column_exists(&conn, "sessions", "acp_config_selection")); assert!(column_exists(&conn, "sessions", "acp_title")); @@ -365,7 +365,7 @@ fn test_store_repairs_pipeline_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 34); + assert_eq!(version, 35); assert!(column_exists(&conn, "comments", "github_comment_id")); assert!(column_exists(&conn, "comments", "github_comment_type")); assert!(column_exists(&conn, "comments", "github_comment_stale")); @@ -454,7 +454,7 @@ fn test_completion_effects_migration_backfills_finished_pipeline_sessions() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 34); + assert_eq!(version, 35); assert!(column_exists(&conn, "sessions", "completion_effects_at")); let marker = |id: &str| -> Option { @@ -530,7 +530,7 @@ fn test_auto_review_removal_migration_deletes_auto_reviews_and_drops_flag() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 34); + assert_eq!(version, 35); assert!(!column_exists(&conn, "reviews", "is_auto")); // Reviews the removed auto-review feature created in the background are @@ -605,7 +605,7 @@ fn test_detecting_pid_migration_clears_orphaned_detection_flags() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 34); + assert_eq!(version, 35); assert!(column_exists(&conn, "action_contexts", "detecting_pid")); // No shipped build ever cleared the flag from outside the process that set @@ -683,7 +683,7 @@ fn test_note_migrations_backfill_subtype_and_completion_for_session_less_notes() let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 34); + assert_eq!(version, 35); assert!(column_exists(&conn, "notes", "subtype")); let subtype = |id: &str| -> Option { @@ -782,7 +782,7 @@ fn test_pinned_actions_migration_pins_each_contexts_first_run_action() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 34); + assert_eq!(version, 35); assert!(column_exists(&conn, "repo_actions", "pinned")); assert!(column_exists(&conn, "repo_actions", "icon")); @@ -853,7 +853,7 @@ fn test_auto_commit_removal_migration_drops_flag_and_keeps_actions() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 34); + assert_eq!(version, 35); assert!(!column_exists(&conn, "repo_actions", "auto_commit")); // Only the flag goes: an action that had auto-commit enabled keeps running @@ -911,7 +911,7 @@ fn test_session_branch_id_backfill_links_artifact_backed_sessions() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 34); + assert_eq!(version, 35); let branch_of = |id: &str| -> Option { conn.query_row( @@ -932,3 +932,57 @@ fn test_session_branch_id_backfill_links_artifact_backed_sessions() { cleanup_db(&path); } + +#[test] +fn test_session_chat_read_at_migration_marks_existing_chats_read() { + let path = temp_db_path("session-chat-read-at"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + " + PRAGMA user_version = 34; + CREATE TABLE app_metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + app_version TEXT NOT NULL + ); + INSERT INTO app_metadata (id, app_version) VALUES (1, '0.2.9'); + -- Only the table 0035 adds its column to. + CREATE TABLE sessions (id TEXT PRIMARY KEY); + INSERT INTO sessions (id) VALUES ('old-a'), ('old-b'); + ", + ) + .unwrap(); + drop(conn); + + let migrated_from = super::now_timestamp() / 1000 * 1000; + let store = Store::new(&path).unwrap(); + drop(store); + let migrated_by = super::now_timestamp(); + + let conn = Connection::open(&path).unwrap(); + let version: i64 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 35); + assert!(column_exists(&conn, "sessions", "chat_read_at")); + + let read_at = |id: &str| -> Option { + conn.query_row( + "SELECT chat_read_at FROM sessions WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .unwrap() + }; + // Chats from before the upgrade read as read, in ms, so the upgrade lights + // no unread dots. + for id in ["old-a", "old-b"] { + let at = read_at(id).unwrap(); + assert!((migrated_from..=migrated_by).contains(&at), "{id}: {at}"); + } + // A session created afterwards has not been read. + conn.execute("INSERT INTO sessions (id) VALUES ('new')", []) + .unwrap(); + assert_eq!(read_at("new"), None); + + cleanup_db(&path); +} diff --git a/apps/staged/src-tauri/src/store/migrations/0035-add-session-chat-read-at/up.sql b/apps/staged/src-tauri/src/store/migrations/0035-add-session-chat-read-at/up.sql new file mode 100644 index 000000000..37f1e4eee --- /dev/null +++ b/apps/staged/src-tauri/src/store/migrations/0035-add-session-chat-read-at/up.sql @@ -0,0 +1,6 @@ +-- When the user last read a session's chat, in ms from the backend clock (the +-- same clock as `session_messages.created_at`). A note row lights its chat +-- button while the chat has replies newer than both the note and this. +ALTER TABLE sessions ADD COLUMN chat_read_at INTEGER; +-- Treat existing chats as read so the upgrade doesn't light up every old note. +UPDATE sessions SET chat_read_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000; diff --git a/apps/staged/src-tauri/src/store/mod.rs b/apps/staged/src-tauri/src/store/mod.rs index 423e9483b..7f63fc7e2 100644 --- a/apps/staged/src-tauri/src/store/mod.rs +++ b/apps/staged/src-tauri/src/store/mod.rs @@ -70,7 +70,9 @@ use tokio::sync::broadcast; /// Sessions are deliberately absent: chat polls at 500ms and session /// lifecycle already flows through `session-status-changed` & friends, so /// session-family writes (sessions, messages, queued messages, session-scoped -/// images, repo actions) publish nothing here. +/// images, repo actions) publish nothing here. The exception is +/// [`Store::mark_session_chat_read`], which publishes [`StoreChange::Notes`] +/// because note rows show whether their chat has unread replies. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum StoreChange { /// A project row or its attached `project_repos` changed. diff --git a/apps/staged/src-tauri/src/store/models.rs b/apps/staged/src-tauri/src/store/models.rs index 9a6892ff9..b47dd02fb 100644 --- a/apps/staged/src-tauri/src/store/models.rs +++ b/apps/staged/src-tauri/src/store/models.rs @@ -1034,6 +1034,13 @@ pub struct ProjectNote { /// Why the session reached its terminal state. #[serde(skip_deserializing)] pub completion_reason: Option, + /// When the session's chat last got a reply it shows. Populated at query + /// time, like `session_status`. + #[serde(skip_deserializing)] + pub last_chat_message_at: Option, + /// When the user last read the session's chat (`sessions.chat_read_at`). + #[serde(skip_deserializing)] + pub chat_read_at: Option, } impl ProjectNote { @@ -1053,6 +1060,8 @@ impl ProjectNote { suggested_next_note_step: None, session_status: None, completion_reason: None, + last_chat_message_at: None, + chat_read_at: None, } } diff --git a/apps/staged/src-tauri/src/store/project_notes.rs b/apps/staged/src-tauri/src/store/project_notes.rs index 8ebe7db91..634c92dcf 100644 --- a/apps/staged/src-tauri/src/store/project_notes.rs +++ b/apps/staged/src-tauri/src/store/project_notes.rs @@ -249,9 +249,27 @@ impl Store { suggested_next_note_step: row.get(9)?, session_status: None, completion_reason: None, + last_chat_message_at: None, + chat_read_at: None, }) } + /// Fill a project note's query-time fields from its session: status, and + /// the chat activity its row's chat button shows. + /// + /// Best-effort, like the status: a lookup miss leaves the chat button in + /// its plain state rather than failing the note or its list. + fn resolve_project_note_session(&self, note: &mut ProjectNote) { + let resolved = self.resolve_session_status(note.session_id.as_deref()); + note.session_status = resolved.status; + note.completion_reason = resolved.completion_reason; + (note.last_chat_message_at, note.chat_read_at) = note + .session_id + .as_deref() + .and_then(|session_id| self.session_chat_activity(session_id).ok()) + .unwrap_or_default(); + } + /// Find a project note by id with session status resolved. /// /// Unlike [`Self::list_project_notes_with_status`] this takes no project @@ -265,9 +283,7 @@ impl Store { Some(n) => n, None => return Ok(None), }; - let resolved = self.resolve_session_status(note.session_id.as_deref()); - note.session_status = resolved.status; - note.completion_reason = resolved.completion_reason; + self.resolve_project_note_session(&mut note); Ok(Some(note)) } @@ -280,22 +296,19 @@ impl Store { Some(n) => n, None => return Ok(None), }; - let resolved = self.resolve_session_status(note.session_id.as_deref()); - note.session_status = resolved.status; - note.completion_reason = resolved.completion_reason; + self.resolve_project_note_session(&mut note); Ok(Some(note)) } - /// Return project notes with session status resolved from the sessions table. + /// Return project notes with session status and chat activity resolved + /// from the sessions table. pub fn list_project_notes_with_status( &self, project_id: &str, ) -> Result, StoreError> { let mut notes = self.list_project_notes(project_id)?; for note in &mut notes { - let resolved = self.resolve_session_status(note.session_id.as_deref()); - note.session_status = resolved.status; - note.completion_reason = resolved.completion_reason; + self.resolve_project_note_session(note); } Ok(notes) } diff --git a/apps/staged/src-tauri/src/store/sessions.rs b/apps/staged/src-tauri/src/store/sessions.rs index 45ec10a2c..7e6c39b56 100644 --- a/apps/staged/src-tauri/src/store/sessions.rs +++ b/apps/staged/src-tauri/src/store/sessions.rs @@ -2,10 +2,11 @@ use rusqlite::{params, Connection, OptionalExtension}; +use super::messages::VISIBLE_ASSISTANT_FILTER; use super::models::{ AcpConfigSelection, CompletionReason, PipelineExecution, Session, SessionStatus, }; -use super::{now_timestamp, Store, StoreError}; +use super::{now_timestamp, Store, StoreChange, StoreError}; impl Store { pub fn create_session(&self, session: &Session) -> Result<(), StoreError> { @@ -596,6 +597,57 @@ impl Store { Ok(()) } + /// Record that the user has read this session's chat up to now. + /// + /// Writes only when the chat has a reply newer than both the last read and + /// the note the session wrote, so reopening a chat with nothing new, or + /// one whose replies all predate its note, neither writes nor reloads + /// anything. Skipping the latter is safe: the chat button ignores the read + /// time until a reply passes the note, and that reply still reads as + /// unread against the older read time. + /// Unlike other session writes this publishes a notes change: the read + /// state shows on the chat button of the note the session wrote. + pub fn mark_session_chat_read(&self, session_id: &str) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + let changed = conn.execute( + &format!( + "UPDATE sessions SET chat_read_at = ?2 + WHERE id = ?1 + AND (SELECT MAX(created_at) FROM session_messages + WHERE session_id = ?1 AND {VISIBLE_ASSISTANT_FILTER}) + > MAX( + COALESCE(chat_read_at, 0), + COALESCE( + (SELECT updated_at FROM notes WHERE session_id = ?1), + (SELECT updated_at FROM project_notes WHERE session_id = ?1), + 0 + ) + )" + ), + params![session_id, now_timestamp()], + )?; + if changed == 0 { + return Ok(()); + } + let branch_id = Self::lookup_id( + &conn, + "SELECT branch_id FROM notes WHERE session_id = ?1", + session_id, + ); + let project_id = Self::lookup_id( + &conn, + "SELECT project_id FROM project_notes WHERE session_id = ?1", + session_id, + ); + if branch_id.is_some() || project_id.is_some() { + self.publish(StoreChange::Notes { + branch_id, + project_id, + }); + } + Ok(()) + } + /// Record that this session's pipeline (pr/push) completion outcome events /// have been delivered, so a later resume of the same session doesn't /// re-fire them against its stale pipeline and transcript. diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index 0c559dd2f..dc0a5a6b9 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -295,6 +295,48 @@ fn test_get_project_note_with_status_is_not_project_scoped() { .is_none()); } +#[test] +fn test_list_project_notes_with_status_fills_chat_activity() { + let store = Store::in_memory().unwrap(); + let project = Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + + let session = Session::new_running("write the project note", Path::new("/tmp")); + store.create_session(&session).unwrap(); + // Written well before its reply: a read only counts replies past the note. + let session_note = ProjectNote { + updated_at: 0, + ..ProjectNote::new(&project.id, "Overview", "Body").with_session(&session.id) + }; + store.create_project_note(&session_note).unwrap(); + let plain_note = ProjectNote::new(&project.id, "Pasted", "Body"); + store.create_project_note(&plain_note).unwrap(); + store + .add_session_message(&session.id, MessageRole::Assistant, "a reply") + .unwrap(); + store.mark_session_chat_read(&session.id).unwrap(); + let (last_reply_at, read_at) = store.session_chat_activity(&session.id).unwrap(); + assert!(last_reply_at.is_some() && read_at.is_some()); + + let notes = store.list_project_notes_with_status(&project.id).unwrap(); + let chat_of = |id: &str| { + let note = notes.iter().find(|note| note.id == id).unwrap(); + (note.last_chat_message_at, note.chat_read_at) + }; + assert_eq!(chat_of(&session_note.id), (last_reply_at, read_at)); + assert_eq!(chat_of(&plain_note.id), (None, None)); + + // The single-note refresh a finished session triggers carries them too. + let refreshed = store + .get_project_note_by_session_with_status(&session.id) + .unwrap() + .unwrap(); + assert_eq!( + (refreshed.last_chat_message_at, refreshed.chat_read_at), + (last_reply_at, read_at) + ); +} + #[test] fn test_delete_project_cascades() { let store = Store::in_memory().unwrap(); @@ -2779,6 +2821,101 @@ fn test_count_assistant_messages_after() { assert_eq!(count, 0); } +#[test] +fn test_session_chat_activity_tracks_the_latest_visible_reply() { + let store = Store::in_memory().unwrap(); + let session = Session::new_running("test", Path::new("/tmp")); + store.create_session(&session).unwrap(); + let set_created_at = |id: i64, created_at: i64| { + store + .conn + .lock() + .unwrap() + .execute( + "UPDATE session_messages SET created_at = ?1 WHERE id = ?2", + rusqlite::params![created_at, id], + ) + .unwrap(); + }; + + assert_eq!( + store.session_chat_activity(&session.id).unwrap(), + (None, None) + ); + + let reply = store + .add_session_message(&session.id, MessageRole::Assistant, "reply") + .unwrap(); + set_created_at(reply, 1_000); + // Rows the chat doesn't show as replies never count, however late. + for role in [ + MessageRole::User, + MessageRole::ToolCall, + MessageRole::ToolResult, + ] { + let id = store + .add_session_message(&session.id, role, "not a reply") + .unwrap(); + set_created_at(id, 5_000); + } + let metadata = store + .add_acp_metadata_message( + &session.id, + &AcpMessageMetadata { + acp_event_kind: Some("session_info_update".to_string()), + ..Default::default() + }, + ) + .unwrap(); + set_created_at(metadata, 5_000); + + assert_eq!( + store.session_chat_activity(&session.id).unwrap(), + (Some(1_000), None) + ); +} + +#[test] +fn test_mark_session_chat_read_only_writes_for_new_replies() { + let store = Store::in_memory().unwrap(); + let session = Session::new_running("test", Path::new("/tmp")); + store.create_session(&session).unwrap(); + let read_at = || store.session_chat_activity(&session.id).unwrap().1; + + // A chat with no replies has nothing to read. + store.mark_session_chat_read(&session.id).unwrap(); + assert_eq!(read_at(), None); + + store + .add_session_message(&session.id, MessageRole::Assistant, "reply") + .unwrap(); + store.mark_session_chat_read(&session.id).unwrap(); + let first_read = read_at().unwrap(); + let reply_at = store.session_chat_activity(&session.id).unwrap().0; + assert!(reply_at.is_some_and(|at| at <= first_read)); + + // Reopening with nothing new leaves the read time alone. + std::thread::sleep(std::time::Duration::from_millis(2)); + store.mark_session_chat_read(&session.id).unwrap(); + assert_eq!(read_at(), Some(first_read)); + + // A reply after the read moves it on at the next mark. + let later = store + .add_session_message(&session.id, MessageRole::Assistant, "later reply") + .unwrap(); + store + .conn + .lock() + .unwrap() + .execute( + "UPDATE session_messages SET created_at = ?1 WHERE id = ?2", + rusqlite::params![first_read + 1, later], + ) + .unwrap(); + store.mark_session_chat_read(&session.id).unwrap(); + assert!(read_at().unwrap() > first_read); +} + // ============================================================================= // Workdirs // ============================================================================= @@ -4401,6 +4538,139 @@ fn change_feed_written_note_edits_publish_like_other_note_mutations() { assert!(rx.try_recv().is_err()); } +#[test] +fn change_feed_marking_a_chat_read_publishes_its_note_only_when_it_writes() { + let (tx, mut rx) = tokio::sync::broadcast::channel(64); + let store = Store::in_memory().unwrap().with_change_sender(tx); + + let project = Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + let branch = Branch::new(&project.id, "feature", "main"); + store.create_branch(&branch).unwrap(); + let branch_session = Session::new_running("branch note", Path::new("/tmp")); + store.create_session(&branch_session).unwrap(); + // Both notes are written well before their replies: a read only counts + // replies past the note. + let note = Note { + updated_at: 0, + ..Note::new(&branch.id, "Plan", "body").with_session(&branch_session.id) + }; + store.create_note(¬e).unwrap(); + let project_session = Session::new_running("project note", Path::new("/tmp")); + store.create_session(&project_session).unwrap(); + let project_note = ProjectNote { + updated_at: 0, + ..ProjectNote::new(&project.id, "Overview", "body").with_session(&project_session.id) + }; + store.create_project_note(&project_note).unwrap(); + for session_id in [&branch_session.id, &project_session.id] { + store + .add_session_message(session_id, MessageRole::Assistant, "a reply") + .unwrap(); + } + while rx.try_recv().is_ok() {} + + // Note rows show the read state, so their lists reload in every window. + store.mark_session_chat_read(&branch_session.id).unwrap(); + assert_eq!( + rx.try_recv().unwrap(), + super::StoreChange::Notes { + branch_id: Some(branch.id.clone()), + project_id: None + } + ); + store.mark_session_chat_read(&project_session.id).unwrap(); + assert_eq!( + rx.try_recv().unwrap(), + super::StoreChange::Notes { + branch_id: None, + project_id: Some(project.id.clone()) + } + ); + + // Reopening a chat with nothing new writes nothing, so it reloads nothing. + store.mark_session_chat_read(&branch_session.id).unwrap(); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn change_feed_marking_a_chat_read_skips_replies_its_note_already_covers() { + let (tx, mut rx) = tokio::sync::broadcast::channel(64); + let store = Store::in_memory().unwrap().with_change_sender(tx); + + let project = Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + let branch = Branch::new(&project.id, "feature", "main"); + store.create_branch(&branch).unwrap(); + let branch_session = Session::new_running("branch note", Path::new("/tmp")); + store.create_session(&branch_session).unwrap(); + let project_session = Session::new_running("project note", Path::new("/tmp")); + store.create_session(&project_session).unwrap(); + let sessions = [&branch_session.id, &project_session.id]; + let add_reply_at = |session_id: &str, created_at: i64| { + let id = store + .add_session_message(session_id, MessageRole::Assistant, "a reply") + .unwrap(); + store + .conn + .lock() + .unwrap() + .execute( + "UPDATE session_messages SET created_at = ?1 WHERE id = ?2", + rusqlite::params![created_at, id], + ) + .unwrap(); + }; + let read_at = |session_id: &str| store.session_chat_activity(session_id).unwrap().1; + + // Each session replies, then writes its note after that reply. + for session_id in sessions { + add_reply_at(session_id, 1_000); + } + store + .create_note(&Note { + updated_at: 2_000, + ..Note::new(&branch.id, "Plan", "body").with_session(&branch_session.id) + }) + .unwrap(); + store + .create_project_note(&ProjectNote { + updated_at: 2_000, + ..ProjectNote::new(&project.id, "Overview", "body").with_session(&project_session.id) + }) + .unwrap(); + while rx.try_recv().is_ok() {} + + // The rows show no chat activity, so opening either chat writes nothing + // and reloads nothing, even though neither has been read. + for session_id in sessions { + store.mark_session_chat_read(session_id).unwrap(); + assert_eq!(read_at(session_id), None); + } + assert!(rx.try_recv().is_err()); + + // A reply past the note is what the next open marks read. + for session_id in sessions { + add_reply_at(session_id, 3_000); + store.mark_session_chat_read(session_id).unwrap(); + assert!(read_at(session_id).is_some_and(|at| at >= 3_000)); + } + assert_eq!( + rx.try_recv().unwrap(), + super::StoreChange::Notes { + branch_id: Some(branch.id.clone()), + project_id: None + } + ); + assert_eq!( + rx.try_recv().unwrap(), + super::StoreChange::Notes { + branch_id: None, + project_id: Some(project.id.clone()) + } + ); +} + #[test] fn change_feed_ensure_review_publishes_only_on_create() { let (tx, mut rx) = tokio::sync::broadcast::channel(64); diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index 083d402c4..e863b3aa0 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -519,6 +519,13 @@ fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result, branch_id: &str) -> Result Result { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + store + .mark_session_chat_read(&session_id) + .map_err(|e| e.to_string())?; + Ok(Value::Null) + } "get_session_acp_metadata_messages" => { let store = get_store(store_mutex)?; let session_id: String = arg(&args, "sessionId")?; diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index bd4991b21..ca699d9b9 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -1050,6 +1050,11 @@ export function countAssistantMessagesAfter( return invokeCommand('count_assistant_messages_after', { sessionId, afterTimestamp }); } +/** Record that the user has read the session's chat, clearing its note row's unread dot. */ +export function markSessionChatRead(sessionId: string): Promise { + return invokeCommand('mark_session_chat_read', { sessionId }); +} + /** Create a session and immediately start the agent. */ export function startSession( prompt: string, diff --git a/apps/staged/src/lib/features/notes/NoteModal.svelte b/apps/staged/src/lib/features/notes/NoteModal.svelte index dab450479..511ba1b11 100644 --- a/apps/staged/src/lib/features/notes/NoteModal.svelte +++ b/apps/staged/src/lib/features/notes/NoteModal.svelte @@ -35,6 +35,7 @@ getProjectNote, getProjectNoteBySession, handleExternalLinkClick, + markSessionChatRead, } from '../../api/commands'; import { listenToEvent } from '../../transport'; import { notesChangeAffectsNote } from './noteRefresh'; @@ -396,6 +397,22 @@ } }); + // Showing the chat marks its replies read, clearing the unread dot on the + // note's row; hiding it (or closing the dialog) does too, covering replies + // that streamed in while it was open. + $effect(() => { + const sid = open && chatOpen ? sessionId : null; + if (!sid) return; + markChatRead(sid); + return () => markChatRead(sid); + }); + + function markChatRead(sid: string) { + markSessionChatRead(sid).catch((error) => { + console.warn('Failed to mark chat read', error); + }); + } + function setChatOpen(next: boolean) { chatOpen = next; onChatOpenChange?.(next); diff --git a/apps/staged/src/lib/features/notes/noteTimestamps.test.ts b/apps/staged/src/lib/features/notes/noteTimestamps.test.ts new file mode 100644 index 000000000..61448fe44 --- /dev/null +++ b/apps/staged/src/lib/features/notes/noteTimestamps.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { formatNoteTimestamp, isNoteRevised, noteLastWrittenAt } from './noteTimestamps'; + +const MINUTE = 60_000; +const DAY = 24 * 60 * MINUTE; +const now = Date.UTC(2026, 9, 30, 12, 0, 0); + +describe('note timestamps', () => { + it('labels and sorts an unrevised session note by when it completed', () => { + const completedAt = now - 5 * MINUTE; + const note = { createdAt: now - 20 * MINUTE, updatedAt: completedAt, completedAt }; + + expect(isNoteRevised(note)).toBe(false); + expect(noteLastWrittenAt(note)).toBe(completedAt); + expect(formatNoteTimestamp(note, now)).toBe('5m ago'); + }); + + it('labels and sorts a revised note by its last update', () => { + const note = { + createdAt: now - 3 * DAY, + updatedAt: now - 2 * 60 * MINUTE, + completedAt: now - 2 * DAY, + }; + + expect(isNoteRevised(note)).toBe(true); + expect(noteLastWrittenAt(note)).toBe(note.updatedAt); + expect(formatNoteTimestamp(note, now)).toBe('Updated 2h ago'); + }); + + it('treats an edited written note as revised', () => { + // Written notes start with all three timestamps equal; edits move only updatedAt. + const writtenAt = now - DAY; + const note = { createdAt: writtenAt, updatedAt: now - 10 * MINUTE, completedAt: writtenAt }; + + expect(isNoteRevised(note)).toBe(true); + expect(noteLastWrittenAt(note)).toBe(note.updatedAt); + expect(formatNoteTimestamp(note, now)).toBe('Updated 10m ago'); + }); + + it('falls back to createdAt for a generating stub', () => { + const note = { createdAt: now - 3 * MINUTE, updatedAt: now - MINUTE, completedAt: null }; + + expect(isNoteRevised(note)).toBe(false); + expect(noteLastWrittenAt(note)).toBe(note.createdAt); + expect(formatNoteTimestamp(note, now)).toBe('3m ago'); + }); + + it('does not treat a failed stub as revised', () => { + // The session ended without content, so completedAt moved past the stub's updatedAt. + const note = { + createdAt: now - 30 * MINUTE, + updatedAt: now - 30 * MINUTE, + completedAt: now - 25 * MINUTE, + }; + + expect(isNoteRevised(note)).toBe(false); + expect(noteLastWrittenAt(note)).toBe(note.completedAt); + expect(formatNoteTimestamp(note, now)).toBe('25m ago'); + }); + + it('keeps the prefix for "just now"', () => { + const note = { createdAt: now - DAY, updatedAt: now - 10_000, completedAt: now - DAY }; + + expect(formatNoteTimestamp(note, now)).toBe('Updated just now'); + }); + + it('keeps the prefix past the 7-day locale-date fallback', () => { + const updatedAt = now - 8 * DAY; + const note = { createdAt: now - 10 * DAY, updatedAt, completedAt: now - 9 * DAY }; + + expect(formatNoteTimestamp(note, now)).toBe( + `Updated ${new Date(updatedAt).toLocaleDateString()}` + ); + }); +}); diff --git a/apps/staged/src/lib/features/notes/noteTimestamps.ts b/apps/staged/src/lib/features/notes/noteTimestamps.ts new file mode 100644 index 000000000..9f7335d5f --- /dev/null +++ b/apps/staged/src/lib/features/notes/noteTimestamps.ts @@ -0,0 +1,31 @@ +import { formatRelativeTime } from '../../shared/relativeTime'; + +/** The timestamp columns shared by branch notes and project notes (all in ms). */ +export interface NoteTimestamps { + createdAt: number; + updatedAt: number; + completedAt: number | null; +} + +/** + * Has the note's content changed since it first landed? + * + * `completedAt` is set once, when content first lands (or the session ends + * without any), while `updatedAt` moves on every content change. Notes from + * before `completedAt` existed were backfilled with `completedAt = updatedAt`, + * so they read as unrevised. + */ +export function isNoteRevised(note: NoteTimestamps): boolean { + return note.completedAt !== null && note.updatedAt > note.completedAt; +} + +/** When the note's content was last written; the key both note lists sort by. */ +export function noteLastWrittenAt(note: NoteTimestamps): number { + return isNoteRevised(note) ? note.updatedAt : (note.completedAt ?? note.createdAt); +} + +/** Row label for a note: "Updated 5m ago" once revised, otherwise "5m ago". */ +export function formatNoteTimestamp(note: NoteTimestamps, nowMs: number): string { + const relative = formatRelativeTime(noteLastWrittenAt(note), nowMs); + return isNoteRevised(note) ? `Updated ${relative}` : relative; +} diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 27c9e5112..6ba3b1609 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -35,7 +35,9 @@ import NewSessionModal from '../sessions/NewSessionModal.svelte'; import { agentState } from '../agents/agent.svelte'; import { getPreferredAgent } from '../settings/preferences.svelte'; - import { formatRelativeTime, minuteNow } from '../../shared/relativeTime.svelte'; + import { minuteNow } from '../../shared/relativeTime.svelte'; + import { formatNoteTimestamp, noteLastWrittenAt } from '../notes/noteTimestamps'; + import { noteChatState } from '../sessions/noteFreshness'; import { buildReferringPrompt } from '../../shared/buildReferringPrompt'; import { createLiveSessionHints } from '../timeline/liveSessionHints'; import { projectDisplayName } from '../../shared/utils'; @@ -299,13 +301,16 @@ } } - /** All notes: completed (oldest first) followed by generating – matches branch timeline order. */ + /** + * All notes: completed (oldest last-written first) followed by generating – matches + * branch timeline order. + */ let timelineNotes = $derived( [...projectNotes].sort((a, b) => { const aIsActive = isSessionActive(a.sessionStatus); const bIsActive = isSessionActive(b.sessionStatus); if (aIsActive !== bIsActive) return aIsActive ? 1 : -1; - return (a.completedAt ?? a.createdAt) - (b.completedAt ?? b.createdAt); + return noteLastWrittenAt(a) - noteLastWrittenAt(b); }) ); let projectNoteContextMenuActions = $derived.by(() => { @@ -625,10 +630,11 @@ ? (liveHint ?? 'Generating note') : isFailed ? undefined - : formatRelativeTime(note.completedAt ?? note.createdAt, nowMs)} + : formatNoteTimestamp(note, nowMs)} deleting={deletingNoteIds.has(note.id)} isLast={false} sessionId={note.sessionId ?? undefined} + chatState={noteType === 'note' ? noteChatState(note) : 'none'} onItemClick={isRunning || isFailed ? undefined : () => openProjectNote(note)} onSessionClick={(sid) => { openProjectNote({ ...note, sessionId: sid }, true); diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts index 940f2082e..33cc3f1a7 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.test.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.test.ts @@ -74,6 +74,8 @@ function projectNote(overrides: Partial = {}): ProjectNote { suggestedNextNoteStep: null, sessionStatus: null, completionReason: null, + lastChatMessageAt: null, + chatReadAt: null, ...overrides, }; } @@ -133,6 +135,8 @@ describe('timelineToHashtagItems', () => { suggestedNextCommitStep: null, suggestedNextNoteStep: null, subtype: null, + lastChatMessageAt: null, + chatReadAt: null, }, { id: 'new-note', @@ -147,6 +151,8 @@ describe('timelineToHashtagItems', () => { suggestedNextCommitStep: null, suggestedNextNoteStep: null, subtype: null, + lastChatMessageAt: null, + chatReadAt: null, }, ], commits: [ @@ -265,6 +271,8 @@ describe('timelineToHashtagItems', () => { suggestedNextCommitStep: null, suggestedNextNoteStep: null, subtype: null, + lastChatMessageAt: null, + chatReadAt: null, }; const timeline = emptyTimeline({ notes: [ diff --git a/apps/staged/src/lib/features/sessions/noteFreshness.test.ts b/apps/staged/src/lib/features/sessions/noteFreshness.test.ts index cc1992731..0e0051841 100644 --- a/apps/staged/src/lib/features/sessions/noteFreshness.test.ts +++ b/apps/staged/src/lib/features/sessions/noteFreshness.test.ts @@ -7,6 +7,7 @@ import { hasNoteFollowupBeenSent, latestAssistantMessage, noteActivityLabel, + noteChatState, type LinkedNoteContext, } from './noteFreshness'; @@ -200,6 +201,37 @@ describe('formatChatButtonLabel', () => { }); }); +describe('noteChatState', () => { + const updatedAt = 5000; + + it('is none when the chat has no replies', () => { + expect(noteChatState({ updatedAt, lastChatMessageAt: null, chatReadAt: null })).toBe('none'); + }); + + it('is none when every reply came before the note was written', () => { + expect(noteChatState({ updatedAt, lastChatMessageAt: 4000, chatReadAt: null })).toBe('none'); + }); + + it('is unread when replies after the note have never been read', () => { + expect(noteChatState({ updatedAt, lastChatMessageAt: 6000, chatReadAt: null })).toBe('unread'); + }); + + it('is read once the chat has been read since the last reply', () => { + expect(noteChatState({ updatedAt, lastChatMessageAt: 6000, chatReadAt: 6000 })).toBe('read'); + expect(noteChatState({ updatedAt, lastChatMessageAt: 6000, chatReadAt: 9000 })).toBe('read'); + }); + + it('is unread when a reply landed after the last read', () => { + expect(noteChatState({ updatedAt, lastChatMessageAt: 8000, chatReadAt: 6000 })).toBe('unread'); + }); + + it('drops back to none when the note is rewritten after the read', () => { + expect(noteChatState({ updatedAt: 9000, lastChatMessageAt: 8000, chatReadAt: 8500 })).toBe( + 'none' + ); + }); +}); + describe('noteActivityLabel', () => { it('uses the live writing label for the last group in a running session', () => { expect(noteActivityLabel({ isLive: true, isLastGroup: true, isFirstNoteMessage: true })).toBe( diff --git a/apps/staged/src/lib/features/sessions/noteFreshness.ts b/apps/staged/src/lib/features/sessions/noteFreshness.ts index d2f902373..7c64bd85e 100644 --- a/apps/staged/src/lib/features/sessions/noteFreshness.ts +++ b/apps/staged/src/lib/features/sessions/noteFreshness.ts @@ -30,6 +30,24 @@ export function countAssistantMessagesAfterNote( ).length; } +/** + * The chat button on a note row: `'none'` when the chat has no replies since + * the note was last written, otherwise `'read'` or `'unread'` by whether the + * user has opened the chat since its latest reply. A turn that rewrites the + * note moves `updatedAt` past every reply, which drops it back to `'none'`. + */ +export type NoteChatState = 'none' | 'read' | 'unread'; + +export function noteChatState(note: { + updatedAt: number; + lastChatMessageAt: number | null; + chatReadAt: number | null; +}): NoteChatState { + const { lastChatMessageAt, chatReadAt } = note; + if (lastChatMessageAt === null || lastChatMessageAt <= note.updatedAt) return 'none'; + return chatReadAt !== null && chatReadAt >= lastChatMessageAt ? 'read' : 'unread'; +} + /** * Formats a label for the "view chat" button in the note modal. * Shows the count of assistant messages after the note was last updated. diff --git a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte index 1a2c0fa41..b03128ad6 100644 --- a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte +++ b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte @@ -25,7 +25,7 @@ NoteSubtype, UpstreamRelation, } from '../../types'; - import type { NoteClickInfo } from '../sessions/noteFreshness'; + import { noteChatState, type NoteChatState, type NoteClickInfo } from '../sessions/noteFreshness'; import type { PendingAdd } from '../branches/timelineImages'; import TimelineRow from './TimelineRow.svelte'; import TimelineContextMenu, { @@ -48,6 +48,7 @@ type PendingHintItemType, } from './liveSessionHints'; import { isEmptyFailedReview } from './reviewState'; + import { formatNoteTimestamp, noteLastWrittenAt } from '../notes/noteTimestamps'; import { reviewBadges } from './reviewBadges'; import { standaloneQueuedPullRowCopy } from './queuedPullRow'; import { canStartQueuedSessionNow, type QueuedSessionKind } from './queuedSessionStart'; @@ -290,6 +291,7 @@ noteContent?: string; noteUpdatedAt?: number; noteSubtype?: NoteSubtype; + chatState?: NoteChatState; reviewId?: string; imageId?: string; imageFilename?: string; @@ -758,7 +760,7 @@ secondaryMeta = liveHint ?? 'Generating note'; } else { type = 'note'; - secondaryMeta = formatRelativeTime(note.completedAt ?? note.createdAt, nowMs); + secondaryMeta = formatNoteTimestamp(note, nowMs); } all.push({ @@ -768,8 +770,8 @@ title: stripXmlTags(note.title), secondaryMeta: isDeleting ? 'Deleting...' : secondaryMeta, deleting: isDeleting, - // Use completedAt so completed notes sort by completion time, not queue time - timestamp: Math.floor((note.completedAt ?? note.createdAt) / 1000), + // Sort by when the content was last written, not queue time + timestamp: Math.floor(noteLastWrittenAt(note) / 1000), order: 0, sessionId: note.sessionId ?? undefined, noteId: note.id, @@ -777,6 +779,7 @@ noteContent: note.content, noteUpdatedAt: note.updatedAt, noteSubtype: note.subtype, + chatState: type === 'note' ? noteChatState(note) : 'none', deleteDisabledReason: isDeleting ? 'Deleting...' : undefined, completionReason: note.completionReason, hashtagRef: type === 'note' ? `#note:${note.id}` : undefined, @@ -1173,6 +1176,7 @@ !error && !actionFooterVisible} sessionId={item.sessionId} + chatState={item.chatState} deleteDisabledReason={isDeletable(item) ? item.deleteDisabledReason : undefined} contextMenuKey={hasContextMenuAction(item) ? item.key : undefined} showConnector={item.showConnector} diff --git a/apps/staged/src/lib/features/timeline/TimelineRow.svelte b/apps/staged/src/lib/features/timeline/TimelineRow.svelte index 1cf0b06aa..bf580ed31 100644 --- a/apps/staged/src/lib/features/timeline/TimelineRow.svelte +++ b/apps/staged/src/lib/features/timeline/TimelineRow.svelte @@ -2,7 +2,8 @@ TimelineRow.svelte - Renders a single timeline item (commit, note, or review) Icon + title + meta. Compact. The whole row is clickable to view the item. - Hover reveals session and delete actions on the right. + Hover reveals session and delete actions on the right; a note's chat button + stays visible while its chat has replies since the note. -->