diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 5ee55853..fadfb299 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -1911,6 +1911,7 @@ pub fn run() { session_commands::get_session, session_commands::get_session_messages, session_commands::get_session_messages_since, + session_commands::count_assistant_messages_after, session_commands::start_session, session_commands::resume_session, session_commands::cancel_session, diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 55aee871..6f477a68 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -119,6 +119,17 @@ pub fn get_session_messages_since( .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn count_assistant_messages_after( + store: tauri::State<'_, Mutex>>>, + session_id: String, + after_timestamp: i64, +) -> Result { + get_store(&store)? + .count_assistant_messages_after(&session_id, after_timestamp) + .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 8b8bf0c1..56b0ecfe 100644 --- a/apps/staged/src-tauri/src/store/messages.rs +++ b/apps/staged/src-tauri/src/store/messages.rs @@ -87,6 +87,22 @@ impl Store { Ok(()) } + /// Count assistant messages created after a given timestamp. + pub fn count_assistant_messages_after( + &self, + session_id: &str, + after_timestamp: i64, + ) -> 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", + params![session_id, after_timestamp], + |row| row.get(0), + )?; + Ok(count) + } + /// Get messages with id >= since_id (inclusive — re-fetches the last known /// message so the caller picks up streaming content updates). pub fn get_session_messages_since( diff --git a/apps/staged/src-tauri/src/store/notes.rs b/apps/staged/src-tauri/src/store/notes.rs index c60b36a8..90213368 100644 --- a/apps/staged/src-tauri/src/store/notes.rs +++ b/apps/staged/src-tauri/src/store/notes.rs @@ -86,6 +86,27 @@ impl Store { suggested_next_note_step: Option<&str>, ) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); + // The session runner re-runs note extraction at the end of every turn for sessions + // with a linked note, even if the assistant didn't rewrite the note. Without this + // short-circuit, `updated_at` would advance on every turn, defeating any freshness + // comparison that relies on it. + let existing: Option<(String, String, Option, Option)> = conn + .query_row( + "SELECT title, content, suggested_next_commit_step, suggested_next_note_step + FROM notes WHERE id = ?1", + params![id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + if let Some((cur_title, cur_content, cur_sncs, cur_snns)) = existing { + if cur_title == title + && cur_content == content + && cur_sncs.as_deref() == suggested_next_commit_step + && cur_snns.as_deref() == suggested_next_note_step + { + return Ok(()); + } + } let now = now_timestamp(); conn.execute( "UPDATE notes SET title = ?1, content = ?2, updated_at = ?3, completed_at = COALESCE(completed_at, ?4), suggested_next_commit_step = ?5, suggested_next_note_step = ?6 WHERE id = ?7", diff --git a/apps/staged/src-tauri/src/store/project_notes.rs b/apps/staged/src-tauri/src/store/project_notes.rs index b41e8515..66089256 100644 --- a/apps/staged/src-tauri/src/store/project_notes.rs +++ b/apps/staged/src-tauri/src/store/project_notes.rs @@ -92,6 +92,27 @@ impl Store { suggested_next_note_step: Option<&str>, ) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); + // The session runner re-runs note extraction at the end of every turn for sessions + // with a linked note, even if the assistant didn't rewrite the note. Without this + // short-circuit, `updated_at` would advance on every turn, defeating any freshness + // comparison that relies on it. + let existing: Option<(String, String, Option, Option)> = conn + .query_row( + "SELECT title, content, suggested_next_commit_step, suggested_next_note_step + FROM project_notes WHERE id = ?1", + params![id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + if let Some((cur_title, cur_content, cur_sncs, cur_snns)) = existing { + if cur_title == title + && cur_content == content + && cur_sncs.as_deref() == suggested_next_commit_step + && cur_snns.as_deref() == suggested_next_note_step + { + return Ok(()); + } + } let now = now_timestamp(); conn.execute( "UPDATE project_notes diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index 60e6efe5..7056e5e2 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -103,6 +103,57 @@ fn test_project_note_completion_is_write_once() { assert!(updated.updated_at >= completed.updated_at); } +#[test] +fn test_update_project_note_title_and_content_is_noop_when_unchanged() { + let store = Store::in_memory().unwrap(); + let project = Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + + let note = ProjectNote::new(&project.id, "", ""); + store.create_project_note(¬e).unwrap(); + + store + .update_project_note_title_and_content( + ¬e.id, + "Title", + "Body", + Some("commit-step"), + Some("note-step"), + ) + .unwrap(); + let after_first = store.get_project_note(¬e.id).unwrap().unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(2)); + + // Same title/content/steps — must not bump updated_at. + store + .update_project_note_title_and_content( + ¬e.id, + "Title", + "Body", + Some("commit-step"), + Some("note-step"), + ) + .unwrap(); + let after_second = store.get_project_note(¬e.id).unwrap().unwrap(); + assert_eq!(after_second.updated_at, after_first.updated_at); + assert_eq!(after_second.completed_at, after_first.completed_at); + + // A real change still advances updated_at. + std::thread::sleep(std::time::Duration::from_millis(2)); + store + .update_project_note_title_and_content( + ¬e.id, + "Title", + "New body", + Some("commit-step"), + Some("note-step"), + ) + .unwrap(); + let after_third = store.get_project_note(¬e.id).unwrap().unwrap(); + assert!(after_third.updated_at > after_first.updated_at); +} + #[test] fn test_list_project_notes_orders_by_completion_time() { let store = Store::in_memory().unwrap(); @@ -623,6 +674,41 @@ fn test_session_messages() { assert_eq!(since[1].id, id2); } +#[test] +fn test_count_assistant_messages_after() { + let store = Store::in_memory().unwrap(); + + let session = Session::new_running("test", Path::new("/tmp")); + store.create_session(&session).unwrap(); + + // Add messages with different roles — timestamps are auto-set via now_timestamp() + // so we use a timestamp of 0 to count all assistant messages. + store + .add_session_message(&session.id, MessageRole::User, "hello") + .unwrap(); + store + .add_session_message(&session.id, MessageRole::Assistant, "hi there") + .unwrap(); + store + .add_session_message(&session.id, MessageRole::User, "more") + .unwrap(); + store + .add_session_message(&session.id, MessageRole::Assistant, "reply") + .unwrap(); + + // All assistant messages are after timestamp 0 + let count = store + .count_assistant_messages_after(&session.id, 0) + .unwrap(); + assert_eq!(count, 2); + + // No assistant messages after a far-future timestamp + let count = store + .count_assistant_messages_after(&session.id, i64::MAX) + .unwrap(); + assert_eq!(count, 0); +} + // ============================================================================= // Workdirs // ============================================================================= @@ -1122,6 +1208,59 @@ fn test_list_notes_for_branch_orders_by_completion_time() { assert_eq!(ordered_ids, vec![older.id.as_str(), newer.id.as_str()]); } +#[test] +fn test_update_note_title_and_content_is_noop_when_unchanged() { + let store = Store::in_memory().unwrap(); + 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 note = Note::new(&branch.id, "", "").with_session("session-1"); + store.create_note(¬e).unwrap(); + + store + .update_note_title_and_content( + ¬e.id, + "Title", + "Body", + Some("commit-step"), + Some("note-step"), + ) + .unwrap(); + let after_first = store.get_note(¬e.id).unwrap().unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(2)); + + // Same title/content/steps — must not bump updated_at. + store + .update_note_title_and_content( + ¬e.id, + "Title", + "Body", + Some("commit-step"), + Some("note-step"), + ) + .unwrap(); + let after_second = store.get_note(¬e.id).unwrap().unwrap(); + assert_eq!(after_second.updated_at, after_first.updated_at); + assert_eq!(after_second.completed_at, after_first.completed_at); + + // A real change still advances updated_at. + std::thread::sleep(std::time::Duration::from_millis(2)); + store + .update_note_title_and_content( + ¬e.id, + "Title", + "New body", + Some("commit-step"), + Some("note-step"), + ) + .unwrap(); + let after_third = store.get_note(¬e.id).unwrap().unwrap(); + assert!(after_third.updated_at > after_first.updated_at); +} + // ============================================================================= // Repo Actions // ============================================================================= diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index bcabd7ff..9c663d21 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -584,6 +584,13 @@ export function getSessionMessagesSince( return invokeCommand('get_session_messages_since', { sessionId, sinceId }); } +export function countAssistantMessagesAfter( + sessionId: string, + afterTimestamp: number +): Promise { + return invokeCommand('count_assistant_messages_after', { sessionId, afterTimestamp }); +} + /** Create a session and immediately start the agent. */ export function startSession( prompt: string, diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index c282359c..d5a8c95d 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -65,6 +65,7 @@ import { getPreferredAgent } from '../settings/preferences.svelte'; import { agentState, REMOTE_AGENTS } from '../agents/agent.svelte'; import type { WorktreeChangesPreview } from '../../commands'; + import type { LinkedNoteContext, NoteClickInfo } from '../sessions/noteFreshness'; interface Props { branch: Branch; @@ -452,6 +453,7 @@ title: string; content: string; sessionId?: string; + noteUpdatedAt?: number; nextSteps?: { commitStep: string | null; noteStep: string | null } | null; } | null>(null); @@ -847,20 +849,31 @@ // ========================================================================= /** Look up note info from timeline data by session ID (for cross-modal navigation). */ - function findNoteForSession( - sessionId: string - ): { id: string; title: string; content: string } | null { - const note = timeline?.notes.find((n) => n.sessionId === sessionId && n.content?.trim()); + function findNoteForSession(sessionId: string): LinkedNoteContext | null { + const note = timeline?.notes.find((n) => n.sessionId === sessionId); if (!note) return null; - return { id: note.id, title: note.title, content: note.content }; + return { + id: note.id, + title: note.title, + content: note.content, + updatedAt: note.updatedAt, + hasParsedNote: !!note.content.trim(), + }; } function handleCommitClick(sha: string) { commitDiffSha = sha; } - function handleNoteClick(noteId: string, title: string, content: string, sessionId?: string) { - openNote = { noteId, title, content, sessionId, nextSteps: computeNoteNextSteps(noteId) }; + function handleNoteClick(note: NoteClickInfo) { + openNote = { + noteId: note.noteId, + title: note.title, + content: note.content, + sessionId: note.sessionId, + noteUpdatedAt: note.updatedAt, + nextSteps: computeNoteNextSteps(note.noteId), + }; } async function handleReviewClick(reviewId: string) { @@ -1571,6 +1584,7 @@ title={openNote.title} content={openNote.content} sessionId={openNote.sessionId} + noteUpdatedAt={openNote.noteUpdatedAt} nextSteps={openNote.nextSteps} onClose={() => (openNote = null)} onOpenSession={(sid) => { @@ -1655,15 +1669,16 @@ projectId={branch.projectId} {repoLabel} noteInfo={findNoteForSession(sessionMgr.openSessionId)} - onOpenNote={(noteId, title, content) => { + onOpenNote={(note) => { const sid = sessionMgr.openSessionId; sessionMgr.openSessionId = null; openNote = { - noteId, - title, - content, + noteId: note.id, + title: note.title, + content: note.content, sessionId: sid ?? undefined, - nextSteps: computeNoteNextSteps(noteId), + noteUpdatedAt: note.updatedAt, + nextSteps: computeNoteNextSteps(note.id), }; }} onClose={async () => { diff --git a/apps/staged/src/lib/features/notes/NoteModal.svelte b/apps/staged/src/lib/features/notes/NoteModal.svelte index d4fc13e3..c0f9a12c 100644 --- a/apps/staged/src/lib/features/notes/NoteModal.svelte +++ b/apps/staged/src/lib/features/notes/NoteModal.svelte @@ -6,11 +6,12 @@ -->