Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -1855,6 +1855,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 @@ -120,6 +120,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
35 changes: 35 additions & 0 deletions apps/staged/src-tauri/src/store/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,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
7 changes: 7 additions & 0 deletions apps/staged/src/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,13 @@ export function getSessionMessagesSince(
return invoke('get_session_messages_since', { sessionId, sinceId });
}

export function countAssistantMessagesAfter(
sessionId: string,
afterTimestamp: number
): Promise<number> {
return invoke('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 @@ -50,6 +50,7 @@
import { sessionRegistry } from '../../stores/sessionRegistry.svelte';
import { getPreferredAgent } from '../settings/preferences.svelte';
import { agentState, REMOTE_AGENTS } from '../agents/agent.svelte';
import type { LinkedNoteContext, NoteClickInfo } from '../sessions/noteFreshness';

interface Props {
branch: Branch;
Expand Down Expand Up @@ -416,6 +417,7 @@
title: string;
content: string;
sessionId?: string;
noteUpdatedAt?: number;
nextSteps?: { commitStep: string | null; noteStep: string | null } | null;
} | null>(null);

Expand Down Expand Up @@ -736,20 +738,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 @@ -1300,6 +1313,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 @@ -1379,15 +1393,16 @@
branchId={branch.id}
projectId={branch.projectId}
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
44 changes: 40 additions & 4 deletions apps/staged/src/lib/features/notes/NoteModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import { marked } from 'marked';
import { sanitize } from '../../shared/sanitize';
import { createBackdropDismissHandlers } from '../../shared/backdropDismiss';
import { handleExternalLinkClick } from '../../api/commands';
import { countAssistantMessagesAfter, handleExternalLinkClick } from '../../api/commands';
import { formatChatButtonLabel } from '../sessions/noteFreshness';
import InContentSearch from '../../shared/InContentSearch.svelte';
import { highlightMatches, clearHighlights, scrollToMatch } from '../../shared/textHighlight';
import { registerSearchShortcutTarget } from '../keyboard/searchTargets';
Expand All @@ -23,18 +24,29 @@
onClose: () => void;
/** When set, shows a button to open the associated chat session. */
sessionId?: string | null;
noteUpdatedAt?: number | null;
onOpenSession?: (sessionId: string) => void;
/** Suggested next steps to show as action buttons at the bottom. */
nextSteps?: { commitStep: string | null; noteStep: string | null } | null;
/** Called when the user clicks a next-step button. */
onStartSession?: (mode: 'commit' | 'note', prefill: string) => void;
}

let { title, content, onClose, sessionId, onOpenSession, nextSteps, onStartSession }: Props =
$props();
let {
title,
content,
onClose,
sessionId,
noteUpdatedAt,
onOpenSession,
nextSteps,
onStartSession,
}: Props = $props();

let copied = $state(false);
const backdropDismiss = createBackdropDismissHandlers({ onDismiss: () => onClose() });
let assistantMessagesAfterNote = $state(0);
let chatButtonLabel = $derived(formatChatButtonLabel(assistantMessagesAfterNote));

// Search state
let searchVisible = $state(false);
Expand All @@ -57,6 +69,30 @@
unregisterSearchTarget?.();
});

$effect(() => {
const sid = sessionId;
const updatedAt = noteUpdatedAt;
if (!sid || typeof updatedAt !== 'number') {
assistantMessagesAfterNote = 0;
return;
}

let stale = false;
countAssistantMessagesAfter(sid, updatedAt)
.then((count) => {
if (!stale) {
assistantMessagesAfterNote = count;
}
})
.catch(() => {
if (!stale) assistantMessagesAfterNote = 0;
});

return () => {
stale = true;
};
});

function renderMarkdown(text: string): string {
return sanitize(marked.parse(text) as string);
}
Expand Down Expand Up @@ -202,7 +238,7 @@
onclick={() => onOpenSession?.(sessionId!)}
title="Open chat session"
>
View chat
{chatButtonLabel}
</button>
{/if}
<button class="close-btn" onclick={onClose} title="Close (Esc)">
Expand Down
38 changes: 30 additions & 8 deletions apps/staged/src/lib/features/projects/ProjectSection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
import { focusAtEnd } from '../../shared/focusAtEnd';
import { buildReferringPrompt } from '../../shared/buildReferringPrompt';
import { createLiveSessionHints } from '../timeline/liveSessionHints';
import type { LinkedNoteContext } from '../sessions/noteFreshness';

interface Props {
project: Project;
Expand Down Expand Up @@ -404,10 +405,26 @@
})
);

let openNote = $state<{ title: string; content: string; sessionId?: string } | null>(null);
let openNote = $state<{
title: string;
content: string;
sessionId?: string;
noteUpdatedAt?: number;
} | null>(null);
let openSessionId = $state<string | null>(null);
let projectContextMenuRef: TimelineContextMenu | undefined = $state();

function linkedNoteContext(note: ProjectNote | undefined): LinkedNoteContext | null {
if (!note) return null;
return {
id: note.id,
title: note.title,
content: note.content,
updatedAt: note.updatedAt,
hasParsedNote: !!note.content.trim(),
};
}

// ── Lifecycle ──────────────────────────────────────────────────────────

onMount(() => {
Expand Down Expand Up @@ -645,6 +662,7 @@
title: note.title,
content: note.content,
sessionId: note.sessionId ?? undefined,
noteUpdatedAt: note.updatedAt,
};
}}
onSessionClick={(sid) => {
Expand Down Expand Up @@ -684,6 +702,7 @@
title={openNote.title}
content={openNote.content}
sessionId={openNote.sessionId}
noteUpdatedAt={openNote.noteUpdatedAt}
onClose={() => (openNote = null)}
onOpenSession={(sid) => {
openNote = null;
Expand All @@ -693,20 +712,23 @@
{/if}

{#if openSessionId}
{@const noteForSession = projectNotes.find(
(n) => n.sessionId === openSessionId && n.content?.trim()
{@const noteForSession = linkedNoteContext(
projectNotes.find((n) => n.sessionId === openSessionId)
)}
<SessionModal
sessionId={openSessionId}
repoDir={branches.find((b) => b.worktreePath)?.worktreePath ?? null}
projectId={project.id}
noteInfo={noteForSession
? { id: noteForSession.id, title: noteForSession.title, content: noteForSession.content }
: null}
onOpenNote={(_noteId, title, content) => {
noteInfo={noteForSession}
onOpenNote={(note) => {
const sid = openSessionId;
openSessionId = null;
openNote = { title, content, sessionId: sid ?? undefined };
openNote = {
title: note.title,
content: note.content,
sessionId: sid ?? undefined,
noteUpdatedAt: note.updatedAt,
};
}}
onClose={() => {
openSessionId = null;
Expand Down
Loading