From bf0187ceaadf61ec4ba0ccc1bf0630af10cca3c3 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 30 Oct 2026 11:38:35 +1100 Subject: [PATCH 1/3] =?UTF-8?q?feat(staged):=20label=20revised=20notes=20"?= =?UTF-8?q?Updated=20=E2=80=A6"=20and=20sort=20note=20lists=20by=20last=20?= =?UTF-8?q?update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project notes list and the branch card timeline both labelled and sorted each note by when it first finished (`completedAt ?? createdAt`), so rewrites by follow-up turns or edits were invisible. Add a small `noteTimestamps` helper, typed on the shared `createdAt`/`updatedAt`/`completedAt` columns so it serves both `NoteTimelineItem` and `ProjectNote`: - `isNoteRevised`: `completedAt` is set and `updatedAt` has moved past it. Migration-backfilled rows (`completedAt = updatedAt`) read as unrevised. - `noteLastWrittenAt`: `updatedAt` once revised, otherwise the old `completedAt ?? createdAt`. - `formatNoteTimestamp`: "Updated 5m ago" once revised, otherwise the existing bare "5m ago". Both lists now use these for the row label and the sort key. The sort direction stays ascending, so a just-updated note moves to the bottom. The backend `ORDER BY` is unchanged: both lists re-sort on the client, and the SQL order also feeds other callers. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- .../lib/features/notes/noteTimestamps.test.ts | 75 +++++++++++++++++++ .../src/lib/features/notes/noteTimestamps.ts | 31 ++++++++ .../features/projects/ProjectSection.svelte | 12 ++- .../features/timeline/BranchTimeline.svelte | 7 +- 4 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 apps/staged/src/lib/features/notes/noteTimestamps.test.ts create mode 100644 apps/staged/src/lib/features/notes/noteTimestamps.ts 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..7956155d7 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -35,7 +35,8 @@ 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 { buildReferringPrompt } from '../../shared/buildReferringPrompt'; import { createLiveSessionHints } from '../timeline/liveSessionHints'; import { projectDisplayName } from '../../shared/utils'; @@ -299,13 +300,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,7 +629,7 @@ ? (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} diff --git a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte index 1a2c0fa41..2d1e9b835 100644 --- a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte +++ b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte @@ -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'; @@ -758,7 +759,7 @@ secondaryMeta = liveHint ?? 'Generating note'; } else { type = 'note'; - secondaryMeta = formatRelativeTime(note.completedAt ?? note.createdAt, nowMs); + secondaryMeta = formatNoteTimestamp(note, nowMs); } all.push({ @@ -768,8 +769,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, From 998ef1153ea5d6de8c181e3c1e54861df3a9a95a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 30 Oct 2026 11:59:24 +1100 Subject: [PATCH 2/3] feat(staged): show read and unread chat replies on note rows The chat button on a note row was a hover-only icon with no state, so replies that arrived in a note's chat after the note was written were invisible from the project notes list and the branch timeline. The note dialog already knew about them, but the lists did not. The button now has three states, decided by `noteChatState` from the chat's latest reply (L), the note's `updatedAt` (U) and the chat's read time (R): - none (L missing or L <= U): unchanged, hover-only "View session". - read (L > U, R >= L): `MessageSquareText` in `--text-muted`, visible without hover. - unread (L > U, R missing or R < L): the same, plus a 6px `--ui-accent` dot on the icon's corner. Only completed note rows get read/unread. A turn that rewrites the note moves U past every reply, so the button drops back to none on its own. Backend: - Migration 0035 adds a nullable `sessions.chat_read_at` (ms, backend clock). Existing rows are backfilled to migration time, so the upgrade doesn't put an unread dot on every old note. - `session_chat_activity` returns the latest visible assistant reply and `chat_read_at` in one query. The visible-reply condition is now one constant shared with `count_assistant_messages_after`. - `mark_session_chat_read` writes the server time only when a reply is newer than the last read. When it writes, it publishes `StoreChange::Notes` for the owning branch or project note, so every window reloads that list. It is the one session-family write that publishes. - `NoteTimelineItem` and `ProjectNote` carry `last_chat_message_at` and `chat_read_at`, filled by the branch timeline, `note_to_timeline_item` and all three project-note `*_with_status` lookups. The project-note lookups share one resolver helper. - New `mark_session_chat_read` command, with web dispatch. Frontend: - `NoteModal` marks the chat read when the chat pane is shown and again when it is hidden or the dialog closes, which covers replies that streamed in while it was open. Clicking the row body is covered too, because the dialog already auto-opens the chat when there are replies after the note. - `TimelineRow` now hides and reveals each action on hover individually instead of fading the whole container, so a chat button with replies can stay visible while Delete and the other actions still wait for hover. Moving a note still bumps `updated_at`, so a moved note's chat button goes back to none. That is intentionally left alone. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/lib.rs | 5 + apps/staged/src-tauri/src/note_commands.rs | 11 ++ apps/staged/src-tauri/src/session_commands.rs | 12 ++ apps/staged/src-tauri/src/store/messages.rs | 35 +++- .../src-tauri/src/store/migration_tests.rs | 74 ++++++- .../0035-add-session-chat-read-at/up.sql | 6 + apps/staged/src-tauri/src/store/mod.rs | 4 +- apps/staged/src-tauri/src/store/models.rs | 9 + .../src-tauri/src/store/project_notes.rs | 30 ++- apps/staged/src-tauri/src/store/sessions.rs | 43 ++++- apps/staged/src-tauri/src/store/tests.rs | 181 ++++++++++++++++++ apps/staged/src-tauri/src/timeline.rs | 47 ++++- apps/staged/src-tauri/src/web_server.rs | 8 + apps/staged/src/lib/commands.ts | 5 + .../src/lib/features/notes/NoteModal.svelte | 17 ++ .../features/projects/ProjectSection.svelte | 2 + .../features/sessions/hashtagItems.test.ts | 8 + .../features/sessions/noteFreshness.test.ts | 32 ++++ .../lib/features/sessions/noteFreshness.ts | 18 ++ .../features/timeline/BranchTimeline.svelte | 5 +- .../lib/features/timeline/TimelineRow.svelte | 67 ++++++- apps/staged/src/lib/types.ts | 8 + 22 files changed, 584 insertions(+), 43 deletions(-) create mode 100644 apps/staged/src-tauri/src/store/migrations/0035-add-session-chat-read-at/up.sql 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..9bc390ee9 100644 --- a/apps/staged/src-tauri/src/store/project_notes.rs +++ b/apps/staged/src-tauri/src/store/project_notes.rs @@ -249,9 +249,24 @@ 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. + fn resolve_project_note_session(&self, note: &mut ProjectNote) -> Result<(), StoreError> { + let resolved = self.resolve_session_status(note.session_id.as_deref()); + note.session_status = resolved.status; + note.completion_reason = resolved.completion_reason; + if let Some(session_id) = note.session_id.as_deref() { + (note.last_chat_message_at, note.chat_read_at) = + self.session_chat_activity(session_id)?; + } + Ok(()) + } + /// Find a project note by id with session status resolved. /// /// Unlike [`Self::list_project_notes_with_status`] this takes no project @@ -265,9 +280,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 +293,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..e52de6a58 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,46 @@ 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 the last read, so + /// reopening a chat with nothing new neither writes nor reloads anything. + /// 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}) + > COALESCE(chat_read_at, 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..dc28a521f 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -295,6 +295,44 @@ 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(); + let session_note = 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 +2817,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 +4534,54 @@ 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(); + let note = 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::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_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..6674fceae 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -519,9 +519,15 @@ fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result store + .session_chat_activity(session_id) + .map_err(|e| e.to_string())?, + None => (None, None), + }; // Session titles are temporary labels and may contain the full prompt. // Keep the stored document title, including when a session resumes. - NoteTimelineItem { + Ok(NoteTimelineItem { id: n.id, title: n.title, content: n.content, @@ -534,9 +540,11 @@ fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result>()?; let db_reviews = store .list_reviews_for_branch(branch_id) @@ -1550,8 +1558,8 @@ mod tests { use super::*; use crate::git::Span; use crate::store::models::{ - Branch, Comment, Commit, Note, PipelineExecution, PipelineKind, Project, ReviewScope, - Session, SessionStatus, Workdir, + Branch, Comment, Commit, MessageRole, Note, PipelineExecution, PipelineKind, Project, + ReviewScope, Session, SessionStatus, Workdir, }; use crate::test_utils::TempGitRepo; use std::fs; @@ -1995,6 +2003,35 @@ mod tests { assert_eq!(title_of(&untitled_note.id), "summarize findings"); } + #[test] + fn build_branch_timeline_note_carries_chat_activity() { + let (repo, _visible_sha, _stale_sha) = repo_with_visible_and_stale_commit(); + let (store, branch) = store_with_branch(&repo); + + let session = running_session(&store, "write up the plan", None); + let session_note = Note::new(&branch.id, "Plan", "body").with_session(&session.id); + store.create_note(&session_note).unwrap(); + let written_note = + Note::new(&branch.id, "Draft", "body").with_subtype(Note::SUBTYPE_WRITTEN); + store.create_note(&written_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 timeline = build_branch_timeline(&store, &branch.id).unwrap(); + + let chat_of = |note_id: &str| { + let item = timeline.notes.iter().find(|n| n.id == note_id).unwrap(); + (item.last_chat_message_at, item.chat_read_at) + }; + assert_eq!(chat_of(&session_note.id), (last_reply_at, read_at)); + // A note with no session has no chat to show. + assert_eq!(chat_of(&written_note.id), (None, None)); + } + #[test] fn build_branch_timeline_resumed_note_ignores_prompt_as_session_title() { let (repo, _visible_sha, _stale_sha) = repo_with_visible_and_stale_commit(); diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 2c7d38cad..cc0e419a5 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -3309,6 +3309,14 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> 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/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 7956155d7..6ba3b1609 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -37,6 +37,7 @@ import { getPreferredAgent } from '../settings/preferences.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'; @@ -633,6 +634,7 @@ 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 2d1e9b835..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, { @@ -291,6 +291,7 @@ noteContent?: string; noteUpdatedAt?: number; noteSubtype?: NoteSubtype; + chatState?: NoteChatState; reviewId?: string; imageId?: string; imageFilename?: string; @@ -778,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, @@ -1174,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..1009639f4 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. -->