Skip to content
1 change: 1 addition & 0 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions apps/staged/src-tauri/src/session_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Arc<Store>>>>,
session_id: String,
after_timestamp: i64,
) -> Result<i64, String> {
get_store(&store)?
.count_assistant_messages_after(&session_id, after_timestamp)
.map_err(|e| e.to_string())
}

// =============================================================================
// Lifecycle commands
// =============================================================================
Expand Down
16 changes: 16 additions & 0 deletions apps/staged/src-tauri/src/store/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64, StoreError> {
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(
Expand Down
21 changes: 21 additions & 0 deletions apps/staged/src-tauri/src/store/notes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, Option<String>)> = 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",
Expand Down
21 changes: 21 additions & 0 deletions apps/staged/src-tauri/src/store/project_notes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, Option<String>)> = 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
Expand Down
139 changes: 139 additions & 0 deletions apps/staged/src-tauri/src/store/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&note).unwrap();

store
.update_project_note_title_and_content(
&note.id,
"Title",
"Body",
Some("commit-step"),
Some("note-step"),
)
.unwrap();
let after_first = store.get_project_note(&note.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(
&note.id,
"Title",
"Body",
Some("commit-step"),
Some("note-step"),
)
.unwrap();
let after_second = store.get_project_note(&note.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(
&note.id,
"Title",
"New body",
Some("commit-step"),
Some("note-step"),
)
.unwrap();
let after_third = store.get_project_note(&note.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();
Expand Down Expand Up @@ -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
// =============================================================================
Expand Down Expand Up @@ -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(&note).unwrap();

store
.update_note_title_and_content(
&note.id,
"Title",
"Body",
Some("commit-step"),
Some("note-step"),
)
.unwrap();
let after_first = store.get_note(&note.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(
&note.id,
"Title",
"Body",
Some("commit-step"),
Some("note-step"),
)
.unwrap();
let after_second = store.get_note(&note.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(
&note.id,
"Title",
"New body",
Some("commit-step"),
Some("note-step"),
)
.unwrap();
let after_third = store.get_note(&note.id).unwrap().unwrap();
assert!(after_third.updated_at > after_first.updated_at);
}

// =============================================================================
// Repo Actions
// =============================================================================
Expand Down
7 changes: 7 additions & 0 deletions apps/staged/src/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,13 @@ export function getSessionMessagesSince(
return invokeCommand('get_session_messages_since', { sessionId, sinceId });
}

export function countAssistantMessagesAfter(
sessionId: string,
afterTimestamp: number
): Promise<number> {
return invokeCommand('count_assistant_messages_after', { sessionId, afterTimestamp });
}

/** Create a session and immediately start the agent. */
export function startSession(
prompt: string,
Expand Down
39 changes: 27 additions & 12 deletions apps/staged/src/lib/features/branches/BranchCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -452,6 +453,7 @@
title: string;
content: string;
sessionId?: string;
noteUpdatedAt?: number;
nextSteps?: { commitStep: string | null; noteStep: string | null } | null;
} | null>(null);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1571,6 +1584,7 @@
title={openNote.title}
content={openNote.content}
sessionId={openNote.sessionId}
noteUpdatedAt={openNote.noteUpdatedAt}
nextSteps={openNote.nextSteps}
onClose={() => (openNote = null)}
onOpenSession={(sid) => {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading