diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 8ad89022..6ed34a8a 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2325,6 +2325,7 @@ pub fn run() { session_commands::get_session_messages, session_commands::get_session_messages_since, session_commands::get_session_acp_metadata_messages, + session_commands::get_session_acp_metadata_messages_since, session_commands::get_session_acp_initialization, session_commands::count_assistant_messages_after, session_commands::start_session, diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 811a0c1f..2bcf9ea6 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -931,6 +931,18 @@ pub fn get_session_acp_metadata_messages( .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn get_session_acp_metadata_messages_since( + store: tauri::State<'_, Mutex>>>, + session_id: String, + since_id: i64, + refetch_ids: Vec, +) -> Result, String> { + get_store(&store)? + .get_session_acp_metadata_messages_since(&session_id, since_id, &refetch_ids) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub fn get_session_acp_initialization( store: tauri::State<'_, Mutex>>>, diff --git a/apps/staged/src-tauri/src/store/messages.rs b/apps/staged/src-tauri/src/store/messages.rs index 8a5bb02d..1cb6b08e 100644 --- a/apps/staged/src-tauri/src/store/messages.rs +++ b/apps/staged/src-tauri/src/store/messages.rs @@ -348,6 +348,43 @@ impl Store { rows.collect::, _>>().map_err(Into::into) } + /// Incremental variant of [`Self::get_session_acp_metadata_messages`]: + /// returns metadata rows with `id > since_id`, plus fresh copies of + /// `refetch_ids`. Rows for tool calls that have not reached a terminal + /// status are updated in place by the message writer rather than + /// re-inserted, so callers pass those ids to observe the mutations. + pub fn get_session_acp_metadata_messages_since( + &self, + session_id: &str, + since_id: i64, + refetch_ids: &[i64], + ) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + let refetch_placeholders = refetch_ids + .iter() + .map(|_| "?") + .collect::>() + .join(", "); + let id_filter = if refetch_ids.is_empty() { + "id > ?".to_string() + } else { + format!("(id > ? OR id IN ({refetch_placeholders}))") + }; + let sql = format!( + "SELECT {SESSION_MESSAGE_COLUMNS} + FROM session_messages + WHERE session_id = ? AND acp_event_kind IS NOT NULL AND {id_filter} + ORDER BY id ASC" + ); + let mut stmt = conn.prepare(&sql)?; + let mut query_params: Vec<&dyn rusqlite::ToSql> = vec![&session_id, &since_id]; + for id in refetch_ids { + query_params.push(id); + } + let rows = stmt.query_map(&query_params[..], session_message_from_row)?; + rows.collect::, _>>().map_err(Into::into) + } + /// Return the latest ACP initialization metadata row for a session. /// /// This exposes negotiated provider/protocol/capability data without diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index 6f57053e..72729951 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -1203,6 +1203,91 @@ fn test_acp_metadata_messages_query_includes_hidden_rows() { assert_eq!(metadata_rows[1].acp.acp_usage.as_ref().unwrap()["used"], 42); } +#[test] +fn test_acp_metadata_messages_since_returns_new_and_refetched_rows() { + let store = Store::in_memory().unwrap(); + + let session = Session::new_running("test", Path::new("/tmp")); + store.create_session(&session).unwrap(); + + // A visible tool_call row whose ACP metadata is later mutated in place — + // the pattern the writer uses for tool status updates. + let tool_id = store + .add_session_message(&session.id, MessageRole::ToolCall, "Run tests") + .unwrap(); + store + .update_message_acp_metadata( + tool_id, + &AcpMessageMetadata { + acp_event_kind: Some("tool_call".to_string()), + acp_tool_call_id: Some("tc-1".to_string()), + acp_tool_status: Some("in_progress".to_string()), + ..Default::default() + }, + ) + .unwrap(); + let usage_id = store + .add_acp_metadata_message( + &session.id, + &AcpMessageMetadata { + acp_event_kind: Some("usage_update".to_string()), + acp_usage: Some(serde_json::json!({"used": 1})), + ..Default::default() + }, + ) + .unwrap(); + // A visible row without ACP metadata never shows up in the metadata query. + store + .add_session_message(&session.id, MessageRole::Assistant, "text") + .unwrap(); + + // Cursor past everything, no refetch ids: nothing to return. + let none = store + .get_session_acp_metadata_messages_since(&session.id, usage_id, &[]) + .unwrap(); + assert!(none.is_empty()); + + // Only rows after the cursor are returned. + let after_tool = store + .get_session_acp_metadata_messages_since(&session.id, tool_id, &[]) + .unwrap(); + assert_eq!(after_tool.len(), 1); + assert_eq!(after_tool[0].id, usage_id); + + // An in-place status update on a row behind the cursor is only observable + // when the caller re-requests that row explicitly. + store + .update_message_acp_metadata( + tool_id, + &AcpMessageMetadata { + acp_tool_status: Some("completed".to_string()), + ..Default::default() + }, + ) + .unwrap(); + let refetched = store + .get_session_acp_metadata_messages_since(&session.id, usage_id, &[tool_id]) + .unwrap(); + assert_eq!(refetched.len(), 1); + assert_eq!(refetched[0].id, tool_id); + assert_eq!( + refetched[0].acp.acp_tool_status.as_deref(), + Some("completed") + ); + + // The full metadata query and the since-query with a zero cursor agree. + let full = store + .get_session_acp_metadata_messages(&session.id) + .unwrap(); + let since_zero = store + .get_session_acp_metadata_messages_since(&session.id, 0, &[]) + .unwrap(); + assert_eq!( + full.iter().map(|m| m.id).collect::>(), + since_zero.iter().map(|m| m.id).collect::>() + ); +} + #[test] fn test_acp_initialization_metadata_is_queryable() { let store = Store::in_memory().unwrap(); diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 47f569c0..1cfcc488 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2828,6 +2828,16 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + let since_id: i64 = arg(&args, "sinceId")?; + let refetch_ids: Vec = arg(&args, "refetchIds")?; + let messages = store + .get_session_acp_metadata_messages_since(&session_id, since_id, &refetch_ids) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(messages).unwrap()) + } "get_session_acp_initialization" => { 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 15577707..79e52fa0 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -776,6 +776,23 @@ export function getSessionAcpMetadataMessages(sessionId: string): Promise sinceId plus fresh copies of refetchIds — rows the caller knows + * may still be mutated in place (tool calls without a terminal status). + */ +export function getSessionAcpMetadataMessagesSince( + sessionId: string, + sinceId: number, + refetchIds: number[] +): Promise { + return invokeCommand('get_session_acp_metadata_messages_since', { + sessionId, + sinceId, + refetchIds, + }); +} + export function countAssistantMessagesAfter( sessionId: string, afterTimestamp: number diff --git a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte index 9fd2b4ae..4ea403b1 100644 --- a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte +++ b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte @@ -69,6 +69,7 @@ getImageData, getSession, getSessionAcpMetadataMessages, + getSessionAcpMetadataMessagesSince, getSessionMessages, getSessionMessagesSince, handleExternalLinkClick, @@ -110,10 +111,14 @@ displayLocations, formatJson, groupRichToolsByVerb, + isToolMetadataSettled, latestAvailableCommands, simpleUnifiedDiff, + stabilizeAcpTranscriptGroups, terminalRefsFromAcpContent, + toolHasDetails, toolResultText, + transcriptGroupKey, type AcpCommand, type AcpTranscriptEvent, type AcpTranscriptGroup, @@ -246,9 +251,21 @@ .map((message) => message.content) .join('\n\n') ); - let grouped = $derived.by(() => - buildAcpTranscriptGroups(messages, acpMetadataMessages, displayRoots) - ); + // Reuse group/item identities across rebuilds so the keyed each only + // re-evaluates groups whose data actually changed — a rebuild happens twice + // per second while a session streams. + let previousGrouped: AcpTranscriptGroup[] = []; + let grouped = $derived.by(() => { + previousGrouped = stabilizeAcpTranscriptGroups( + previousGrouped, + buildAcpTranscriptGroups(messages, acpMetadataMessages, displayRoots) + ); + return previousGrouped; + }); + /** Number-valued so per-group template expressions can depend on "am I the + * last group?" without re-running whenever the grouped array identity + * changes — deriveds cut propagation when the value is equal. */ + let lastGroupIndex = $derived(grouped.length - 1); /** Pikchr sources of successful render_pikchr tool calls, shown inline as diagrams. */ let pikchrToolSources = $derived( grouped.flatMap((group) => @@ -693,7 +710,12 @@ } } - /** Incremental poll — re-fetch last message (may have grown) + any new ones. */ + /** Incremental poll — re-fetch last message (may have grown) + any new ones. + * + * State assignments are skipped whenever a tick brings no actual change: + * reassigning the same data under a fresh identity would invalidate the + * grouped transcript and re-render the entire message list twice a second. + */ async function poll() { if (!session || closed || pollInFlight) return; pollInFlight = true; @@ -703,30 +725,39 @@ const s = await getSession(sessionId); if (closed) return; const statusEventDuringFetch = statusEventVersion !== statusVersionBeforeFetch; - if (s && !statusEventDuringFetch) session = s; + if (s && !statusEventDuringFetch && JSON.stringify(s) !== JSON.stringify(session)) { + session = s; + } await refreshQueuedMessages(); - // Incremental message fetch + // Incremental message fetch. Metadata rows for unsettled tool calls are + // mutated in place by the backend writer rather than re-inserted, so + // they're re-requested by id alongside rows past the cursor. + const metadataCursor = + acpMetadataMessages.length > 0 ? acpMetadataMessages[acpMetadataMessages.length - 1].id : 0; + const unsettledIds = acpMetadataMessages + .filter((message) => message.acpToolCallId && !isToolMetadataSettled(message)) + .map((message) => message.id); if (messages.length === 0) { - const [{ data: msgs }, acpMsgs] = await Promise.all([ + const [{ data: msgs }, acpUpdates] = await Promise.all([ getSessionMessages(sessionId), - getSessionAcpMetadataMessages(sessionId), + getSessionAcpMetadataMessagesSince(sessionId, metadataCursor, unsettledIds), ]); if (closed) return; - acpMetadataMessages = acpMsgs; + applyAcpMetadataUpdates(acpUpdates); if (msgs.length > 0) { messages = msgs; scrollToBottomIfNear(true); } } else { const lastId = messages[messages.length - 1].id; - const [updated, acpMsgs] = await Promise.all([ + const [updated, acpUpdates] = await Promise.all([ getSessionMessagesSince(sessionId, lastId), - getSessionAcpMetadataMessages(sessionId), + getSessionAcpMetadataMessagesSince(sessionId, metadataCursor, unsettledIds), ]); if (closed) return; - acpMetadataMessages = acpMsgs; - if (updated.length > 0) { + applyAcpMetadataUpdates(acpUpdates); + if (updated.length > 0 && !isUnchangedTail(updated, lastId)) { const prev = messages.slice(0, -1); messages = [...prev, ...updated]; if (updated.length > 1 || updated[0].id !== lastId) { @@ -748,10 +779,45 @@ } } + /** + * Merge incremental metadata rows into the cached array. Row identity is + * preserved when a re-fetched unsettled row comes back unchanged, and array + * identity is preserved when no row changed at all, so unchanged transcript + * groups keep their identity downstream. + */ + function applyAcpMetadataUpdates(updates: SessionMessage[]) { + if (updates.length === 0) return; + const byId = new Map(acpMetadataMessages.map((message) => [message.id, message])); + let changed = false; + for (const update of updates) { + const current = byId.get(update.id); + if (current && JSON.stringify(current) === JSON.stringify(update)) continue; + byId.set(update.id, update); + changed = true; + } + if (!changed) return; + acpMetadataMessages = [...byId.values()].sort((a, b) => a.id - b.id); + } + + /** Whether the since-fetch returned only an identical copy of the last + * known message. Content is compared separately so the common streaming + * case (the tail grew) skips the JSON pass over the metadata fields. */ + function isUnchangedTail(updated: SessionMessage[], lastId: number): boolean { + if (updated.length !== 1 || updated[0].id !== lastId) return false; + const current = messages[messages.length - 1]; + if (updated[0].content !== current.content) return false; + return ( + JSON.stringify({ ...updated[0], content: '' }) === JSON.stringify({ ...current, content: '' }) + ); + } + async function refreshQueuedMessages() { if (!sessionId || closed) return; try { - queuedMessages = await listQueuedSessionMessages(sessionId); + const queued = await listQueuedSessionMessages(sessionId); + if (JSON.stringify(queued) !== JSON.stringify(queuedMessages)) { + queuedMessages = queued; + } } catch (e) { console.error('Failed to refresh queued messages:', e); } @@ -1014,13 +1080,41 @@ } } + /** LRU cache for rendered markdown, keyed by source string. Live-transcript + * re-renders mostly re-request unchanged content, and each render is a full + * marked parse + DOMPurify sanitize (plus synchronous WASM renders for + * pikchr fences). Output is deterministic per content for a given renderer + * and hashtag context, so the cache resets when either changes. */ + const MARKDOWN_CACHE_LIMIT = 500; + const markdownHtmlCache = new Map(); + let markdownCacheRenderer: PikchrRenderer | null = null; + let markdownCacheHashtagItems: HashtagItem[] | null = null; + function renderMarkdown(content: string): string { - return renderSharedMarkdown(content, { - pikchrRenderer, - renderInlineText: hashtagItems.length - ? (text) => renderHashtagTokens(text, hashtagItems) - : undefined, + // Read both reactive inputs unconditionally so cached calls keep the same + // dependency tracking as uncached ones. + const renderer = pikchrRenderer; + const items = hashtagItems; + if (markdownCacheRenderer !== renderer || markdownCacheHashtagItems !== items) { + markdownHtmlCache.clear(); + markdownCacheRenderer = renderer; + markdownCacheHashtagItems = items; + } + const cached = markdownHtmlCache.get(content); + if (cached !== undefined) { + markdownHtmlCache.delete(content); + markdownHtmlCache.set(content, cached); + return cached; + } + const html = renderSharedMarkdown(content, { + pikchrRenderer: renderer, + renderInlineText: items.length ? (text) => renderHashtagTokens(text, items) : undefined, }); + markdownHtmlCache.set(content, html); + if (markdownHtmlCache.size > MARKDOWN_CACHE_LIMIT) { + markdownHtmlCache.delete(markdownHtmlCache.keys().next().value!); + } + return html; } function openDiagramViewerFromEvent(event: MouseEvent | KeyboardEvent): boolean { @@ -1508,11 +1602,22 @@ .slice(0, 6); }); + /** Note-indicator scans keyed on the message object: rows keep identity + * across polls except the re-fetched tail, so stale entries self-evict. */ + const noteIndicatorCache = new WeakMap(); + + function messageHasNoteIndicator(message: SessionMessage): boolean { + let hasNote = noteIndicatorCache.get(message); + if (hasNote === undefined) { + hasNote = splitAtNoteIndicator(message.content).hasNote; + noteIndicatorCache.set(message, hasNote); + } + return hasNote; + } + let noteBearingMessageIds = $derived.by(() => { return messages - .filter( - (message) => message.role === 'assistant' && splitAtNoteIndicator(message.content).hasNote - ) + .filter((message) => message.role === 'assistant' && messageHasNoteIndicator(message)) .map((message) => message.id); }); @@ -1622,12 +1727,6 @@ return text.length > 72 ? `${text.slice(0, 72)}…` : text; } - function groupKey(group: AcpTranscriptGroup): string { - if (group.type === 'tools') return `t-${group.items[0].key}`; - if (group.type === 'acp') return `a-${group.event.id}`; - return `m-${group.message.id}`; - } - /** Slide-in transition that is suppressed during the initial load. */ function messageSlide(node: Element) { if (!animateNewMessages) return { duration: 0 }; @@ -1661,19 +1760,7 @@ {#snippet richToolCard(item: RichToolItem, nested: boolean)} {@const isExpanded = expandedTools.has(item.key)} - {@const resultText = toolResultText(item)} - {@const rawInputText = formatJson(item.rawInput)} - {@const rawOutputText = formatJson(item.rawOutput)} - {@const diffs = diffsFromAcpContent(item.content, displayRoots)} - {@const locations = displayLocations(item.locations, displayRoots)} - {@const terminalRefs = terminalRefsFromAcpContent(item.content)} - {@const hasDetails = - !!resultText || - !!rawInputText || - !!rawOutputText || - diffs.length > 0 || - locations.length > 0 || - terminalRefs.length > 0} + {@const hasDetails = toolHasDetails(item)} {@const showSessionButton = !!(item.isPikchrDiagramTool && item.innerSessionId && onOpenSession)} {@const showInlineDiagram = item.pikchrRenderSource !== null}
@@ -1719,6 +1806,14 @@
{/if} {#if isExpanded && hasDetails && !showSessionButton && !showInlineDiagram} + + {@const resultText = toolResultText(item)} + {@const rawInputText = formatJson(item.rawInput)} + {@const rawOutputText = formatJson(item.rawOutput)} + {@const diffs = diffsFromAcpContent(item.content, displayRoots)} + {@const locations = displayLocations(item.locations, displayRoots)} + {@const terminalRefs = terminalRefsFromAcpContent(item.content)}
{#if (item.verb === 'Ran' || item.verb === 'Running') && item.detail}
$ {item.detail}
@@ -1809,7 +1904,7 @@ {:else}
- {#each grouped as group, groupIdx (groupKey(group))} + {#each grouped as group, groupIdx (transcriptGroupKey(group))}
{#if group.type === 'user'} {@const hasBlocks = cachedHasXmlBlocks(group.message.content)} @@ -1897,7 +1992,7 @@ {/if} {:else if group.type === 'assistant'} {@const split = splitAtNoteIndicator(group.message.content, { - streaming: isLive && groupIdx === grouped.length - 1, + streaming: isLive && groupIdx === lastGroupIndex, })} {@const firstNoteMessageId = noteBearingMessageIds[0]} {@const hasPreamble = split.preamble.trim().length > 0} @@ -1916,7 +2011,7 @@ diff --git a/apps/staged/src/lib/features/sessions/acpTranscript.test.ts b/apps/staged/src/lib/features/sessions/acpTranscript.test.ts index a3eacede..0db0d2a4 100644 --- a/apps/staged/src/lib/features/sessions/acpTranscript.test.ts +++ b/apps/staged/src/lib/features/sessions/acpTranscript.test.ts @@ -3,7 +3,10 @@ import type { SessionMessage } from '../../types'; import { buildAcpTranscriptGroups, groupRichToolsByVerb, + isToolMetadataSettled, latestAvailableCommands, + stabilizeAcpTranscriptGroups, + toolHasDetails, type RichToolItem, } from './acpTranscript'; @@ -693,6 +696,152 @@ describe('latestAvailableCommands', () => { }); }); +describe('stabilizeAcpTranscriptGroups', () => { + const transcript = (visible: SessionMessage[], metadata: SessionMessage[]) => + buildAcpTranscriptGroups(visible, metadata, '/repo'); + + it('returns the previous array identity when nothing changed', () => { + const visible = [ + message({ id: 1, role: 'user', content: 'go' }), + message({ + id: 2, + role: 'tool_call', + content: JSON.stringify({ name: 'Read', input: { file_path: '/repo/a.ts' } }), + acpEventKind: 'tool_call', + acpToolCallId: 'tc-1', + acpToolStatus: 'completed', + }), + message({ id: 3, role: 'assistant', content: 'done' }), + ]; + const metadata = [visible[1]]; + + const first = transcript(visible, metadata); + const second = stabilizeAcpTranscriptGroups(first, transcript(visible, metadata)); + + expect(second).toBe(first); + }); + + it('reuses unchanged groups while replacing the changed tail', () => { + const user = message({ id: 1, role: 'user', content: 'go' }); + const first = transcript([user, message({ id: 2, role: 'assistant', content: 'partial' })], []); + const grown = transcript( + [user, message({ id: 2, role: 'assistant', content: 'partial plus more' })], + [] + ); + + const stabilized = stabilizeAcpTranscriptGroups(first, grown); + + expect(stabilized).not.toBe(first); + expect(stabilized[0]).toBe(first[0]); + expect(stabilized[1]).not.toBe(first[1]); + if (stabilized[1].type === 'assistant') { + expect(stabilized[1].message.content).toBe('partial plus more'); + } + }); + + it('reuses unchanged tool items inside a changed tools group', () => { + const toolCall = (id: number, toolCallId: string, status: string) => + message({ + id, + role: 'tool_call', + content: JSON.stringify({ name: 'Read', input: { file_path: `/repo/${toolCallId}.ts` } }), + acpEventKind: 'tool_call', + acpToolCallId: toolCallId, + acpToolStatus: status, + }); + + const settled = toolCall(1, 'tc-1', 'completed'); + const first = transcript([settled, toolCall(2, 'tc-2', 'in_progress')], []); + const next = transcript([settled, toolCall(2, 'tc-2', 'completed')], []); + + const stabilized = stabilizeAcpTranscriptGroups(first, next); + + expect(stabilized[0]).not.toBe(first[0]); + if (stabilized[0].type === 'tools' && first[0].type === 'tools') { + expect(stabilized[0].items[0]).toBe(first[0].items[0]); + expect(stabilized[0].items[1]).not.toBe(first[0].items[1]); + expect(stabilized[0].items[1].status).toBe('completed'); + } + }); + + it('detects in-place metadata changes on an existing tool row', () => { + const call = message({ + id: 1, + role: 'tool_call', + content: JSON.stringify({ name: 'Run', input: { command: 'npm test' } }), + acpEventKind: 'tool_call', + acpToolCallId: 'tc-1', + acpToolStatus: 'in_progress', + }); + const first = transcript([call], [call]); + const updated = { ...call, acpToolStatus: 'completed', acpRawOutput: { exitCode: 0 } }; + const next = transcript([call], [updated]); + + const stabilized = stabilizeAcpTranscriptGroups(first, next); + + expect(stabilized[0]).not.toBe(first[0]); + if (stabilized[0].type === 'tools') { + expect(stabilized[0].items[0].status).toBe('completed'); + } + }); +}); + +describe('toolHasDetails', () => { + it('matches the formatted-value emptiness checks', () => { + expect(toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran' }))).toBe(false); + expect(toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran', rawInput: '' }))).toBe(false); + expect(toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran', rawInput: { a: 1 } }))).toBe(true); + expect(toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran', rawOutput: 'ok' }))).toBe(true); + expect( + toolHasDetails( + richTool({ + key: 'tool:1', + verb: 'Ran', + content: [{ type: 'diff', path: '/repo/a.ts', newText: 'new' }], + }) + ) + ).toBe(true); + expect( + toolHasDetails( + richTool({ key: 'tool:1', verb: 'Ran', content: [{ type: 'terminal', terminalId: 't-1' }] }) + ) + ).toBe(true); + expect( + toolHasDetails( + richTool({ key: 'tool:1', verb: 'Ran', locations: [{ path: '/repo/a.ts', line: 3 }] }) + ) + ).toBe(true); + expect( + toolHasDetails( + richTool({ + key: 'tool:1', + verb: 'Ran', + result: message({ id: 9, role: 'tool_result', content: 'output' }), + }) + ) + ).toBe(true); + // Presence-only fields that the expanded card ignores stay hidden. + expect( + toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran', content: [{ type: 'diff' }] })) + ).toBe(false); + expect(toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran', locations: [{}] }))).toBe(false); + }); +}); + +describe('isToolMetadataSettled', () => { + it('treats terminal statuses as settled and everything else as mutable', () => { + const row = (status?: string) => + message({ id: 1, role: 'assistant', acpToolCallId: 'tc-1', acpToolStatus: status }); + + expect(isToolMetadataSettled(row('completed'))).toBe(true); + expect(isToolMetadataSettled(row('failed'))).toBe(true); + expect(isToolMetadataSettled(row('cancelled'))).toBe(true); + expect(isToolMetadataSettled(row('in_progress'))).toBe(false); + expect(isToolMetadataSettled(row('pending'))).toBe(false); + expect(isToolMetadataSettled(row(undefined))).toBe(false); + }); +}); + function richTool( overrides: Partial & Pick ): RichToolItem { diff --git a/apps/staged/src/lib/features/sessions/acpTranscript.ts b/apps/staged/src/lib/features/sessions/acpTranscript.ts index ed73958f..cd0efa4f 100644 --- a/apps/staged/src/lib/features/sessions/acpTranscript.ts +++ b/apps/staged/src/lib/features/sessions/acpTranscript.ts @@ -159,6 +159,109 @@ export function buildAcpTranscriptGroups( return groups; } +/** Stable identity key for a transcript group, used both as the keyed-each key + * and to pair groups across rebuilds in [`stabilizeAcpTranscriptGroups`]. */ +export function transcriptGroupKey(group: AcpTranscriptGroup): string { + if (group.type === 'tools') return `t-${group.items[0].key}`; + if (group.type === 'acp') return `a-${group.event.id}`; + return `m-${group.message.id}`; +} + +/** + * Reuse group (and tool item) object identities from a previous build for + * entries whose underlying data is unchanged. buildAcpTranscriptGroups creates + * every object from scratch, which would make a keyed each re-evaluate the + * whole transcript on every poll tick; with reused identities only groups that + * actually changed re-render. Returns `previous` itself when nothing changed + * at all, so downstream deriveds short-circuit too. + */ +export function stabilizeAcpTranscriptGroups( + previous: AcpTranscriptGroup[], + next: AcpTranscriptGroup[] +): AcpTranscriptGroup[] { + if (previous.length === 0) return next; + const previousByKey = new Map(); + for (const group of previous) previousByKey.set(transcriptGroupKey(group), group); + + const stabilized = next.map((group) => { + const prior = previousByKey.get(transcriptGroupKey(group)); + return prior ? reuseTranscriptGroup(prior, group) : group; + }); + + if ( + stabilized.length === previous.length && + stabilized.every((group, index) => group === previous[index]) + ) { + return previous; + } + return stabilized; +} + +function reuseTranscriptGroup( + prior: AcpTranscriptGroup, + next: AcpTranscriptGroup +): AcpTranscriptGroup { + if (prior.type !== next.type) return next; + if (next.type === 'user' || next.type === 'assistant') { + return (prior as typeof next).message === next.message ? prior : next; + } + if (next.type === 'acp') { + const priorEvent = (prior as typeof next).event; + return priorEvent.id === next.event.id && + priorEvent.kind === next.event.kind && + priorEvent.content === next.event.content && + priorEvent.message === next.event.message + ? prior + : next; + } + + const priorItems = (prior as typeof next).items; + const priorByKey = new Map(priorItems.map((item) => [item.key, item])); + let reusedInPlace = priorItems.length === next.items.length; + const items = next.items.map((item, index) => { + const priorItem = priorByKey.get(item.key); + if (!priorItem || !richToolItemsEqual(priorItem, item)) { + reusedInPlace = false; + return item; + } + if (priorItem !== priorItems[index]) reusedInPlace = false; + return priorItem; + }); + return reusedInPlace ? prior : { ...next, items }; +} + +/** All fields are primitives or references into the source message rows, so + * strict equality detects change as long as unchanged rows keep identity. */ +function richToolItemsEqual(a: RichToolItem, b: RichToolItem): boolean { + return ( + a.key === b.key && + a.call === b.call && + a.result === b.result && + a.verb === b.verb && + a.detail === b.detail && + a.status === b.status && + a.toolCallId === b.toolCallId && + a.toolKind === b.toolKind && + a.rawInput === b.rawInput && + a.rawOutput === b.rawOutput && + a.content === b.content && + a.locations === b.locations && + a.isPikchrDiagramTool === b.isPikchrDiagramTool && + a.innerSessionId === b.innerSessionId && + a.pikchrRenderSource === b.pikchrRenderSource + ); +} + +/** + * Whether a metadata row for a tool call has reached a terminal status. Rows + * that haven't may still be mutated in place by the backend message writer, + * so incremental metadata polling must re-request them by id. + */ +export function isToolMetadataSettled(message: SessionMessage): boolean { + const status = normalizeToolStatus(message.acpToolStatus); + return status === 'completed' || status === 'failed' || status === 'cancelled'; +} + export function latestAvailableCommands(metadataMessages: SessionMessage[]): AcpCommand[] { const latest = [...metadataMessages] .reverse() @@ -792,3 +895,52 @@ export function toolResultText(item: RichToolItem): string { if (item.result?.content) return stripCodeFences(item.result.content); return ''; } + +/** + * Whether an expanded tool card would have anything to show. Equivalent to + * checking the formatted values (`formatJson`, `toolResultText`, + * `diffsFromAcpContent`, …) for emptiness, but without building them — the + * formatted strings are only needed once a card is actually expanded, and + * pretty-printing large raw payloads for every collapsed card is what made + * live-transcript re-renders expensive. + */ +export function toolHasDetails(item: RichToolItem): boolean { + return ( + hasJsonValue(item.rawInput) || + hasJsonValue(item.rawOutput) || + acpContentHasDiff(item.content) || + acpContentHasTerminalRef(item.content) || + hasLocation(item.locations) || + !!toolResultText(item) + ); +} + +/** Mirrors `!!formatJson(value)`: only undefined/null/'' format to ''. */ +function hasJsonValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== ''; +} + +/** Mirrors `diffsFromAcpContent(content).length > 0`. */ +function acpContentHasDiff(content: unknown): boolean { + if (!Array.isArray(content)) return false; + return content.some( + (item) => + stringProp(item, 'type') === 'diff' && + !!stringProp(item, 'path') && + stringProp(item, 'newText') !== null + ); +} + +/** Mirrors `terminalRefsFromAcpContent(content).length > 0`. */ +function acpContentHasTerminalRef(content: unknown): boolean { + if (!Array.isArray(content)) return false; + return content.some( + (item) => stringProp(item, 'type') === 'terminal' && !!stringProp(item, 'terminalId') + ); +} + +/** Mirrors `displayLocations(locations).length > 0`. */ +function hasLocation(locations: unknown): boolean { + if (!Array.isArray(locations)) return false; + return locations.some((location) => !!stringProp(location, 'path')); +}