diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 019b4d7d9..4da048712 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -3,6 +3,7 @@ import { AlertCircle, Bot, Brain, + CheckCheck, ChevronDown, CircleDot, Loader2, @@ -19,9 +20,18 @@ import { cn } from "@/shared/lib/cn"; import { Badge } from "@/shared/ui/badge"; import { Markdown } from "@/shared/ui/markdown"; import { Shimmer } from "@/shared/ui/Shimmer"; +import { Toggle } from "@/shared/ui/toggle"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import type { TranscriptItem } from "./agentSessionTypes"; +import type { PromptSection, TranscriptItem } from "./agentSessionTypes"; import { ToolItem } from "./AgentSessionToolItem"; +import { + buildTranscriptDisplayBlocks, + formatTurnSetupLabel, + turnSetupDetail, + turnSetupTimestamp, + type TranscriptDisplayBlock, + type TranscriptTurnSegment, +} from "./agentSessionTranscriptGrouping"; import { buildTranscriptPresentation } from "./agentSessionTranscriptPresentation"; import { formatTranscriptTime } from "./agentSessionUtils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -50,6 +60,10 @@ export function AgentSessionTranscriptList({ () => buildTranscriptPresentation(items, isWorking), [items, isWorking], ); + const displayBlocks = React.useMemo( + () => buildTranscriptDisplayBlocks(items), + [items], + ); if (items.length === 0) { return ( @@ -81,26 +95,15 @@ export function AgentSessionTranscriptList({ className={cn("w-full", compact ? "py-0.5" : "py-1")} role="log" > - {items.map((item) => ( -
- {SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? ( - - ) : null} - -
+ {displayBlocks.map((block) => ( + ))} @@ -315,6 +318,351 @@ function getStateLabel( } } +function getDisplayBlockKey(block: TranscriptDisplayBlock) { + if (block.kind === "single") { + return block.item.id; + } + return `turn:${block.turnId}`; +} + +function TranscriptDisplayBlockView({ + activeItemIds, + agentName, + block, + compact, + profiles, +}: { + activeItemIds: ReadonlySet; + agentName: string; + block: TranscriptDisplayBlock; + compact: boolean; + profiles?: UserProfileLookup; +}) { + if (block.kind === "single") { + return ( + + ); + } + + return ( +
+ {block.segments.map((segment) => ( + + ))} +
+ ); +} + +function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { + if (segment.kind === "setup") { + return `turn:${turnId}:setup`; + } + if (segment.kind === "prompt") { + return `turn:${turnId}:prompt`; + } + return segment.item.id; +} + +function TranscriptTurnSegmentView({ + activeItemIds, + agentName, + compact, + profiles, + segment, +}: { + activeItemIds: ReadonlySet; + agentName: string; + compact: boolean; + profiles?: UserProfileLookup; + segment: TranscriptTurnSegment; +}) { + if (segment.kind === "prompt") { + return ( + + ); + } + + if (segment.kind === "setup") { + return ; + } + + return ( + + ); +} + +function TurnPromptBlock({ + compact, + context, + profiles, + setup, + user, +}: { + compact: boolean; + context: Extract | null; + profiles?: UserProfileLookup; + setup: Extract[]; + user: Extract; +}) { + return ( +
+ {SHOW_TRANSCRIPT_ACP_SOURCE ? ( +
+ + {context ? ( + + ) : null} +
+ ) : null} + +
+ ); +} + +function PromptUserMessage({ + compact, + context = null, + item, + profiles, + setup = [], +}: { + compact: boolean; + context?: Extract | null; + item: Extract; + profiles?: UserProfileLookup; + setup?: Extract[]; +}) { + const [contextOpen, setContextOpen] = React.useState(false); + const text = item.text.trim(); + const authorProfile = item.authorPubkey + ? profiles?.[item.authorPubkey.toLowerCase()] + : null; + const authorLabel = item.authorPubkey + ? resolveUserLabel({ + pubkey: item.authorPubkey, + fallbackName: item.title, + profiles, + }) + : item.title || "User"; + + return ( +
+ +
+
+

{text}

+ {contextOpen && context ? ( + + ) : null} +
+ +
+
+ ); +} + +function PromptContextSections({ sections }: { sections: PromptSection[] }) { + return ( +
+ {sections.map((section) => ( +
+ + {section.title} + + +
+            {section.body.trim() || "No metadata."}
+          
+
+ ))} +
+ ); +} + +function TurnSetupFooter({ + context = null, + contextOpen = false, + items, + onContextOpenChange, + timestamp, +}: { + context?: Extract | null; + contextOpen?: boolean; + items: Extract[]; + onContextOpenChange?: (open: boolean) => void; + timestamp: string; +}) { + const label = formatTurnSetupLabel(items); + const detail = turnSetupDetail(items); + const tooltipText = [label, detail].filter(Boolean).join(" · "); + const showSetup = items.length > 0; + const showContext = context != null && context.sections.length > 0; + + if (!showSetup && !showContext) { + return ; + } + + return ( +
+ {showSetup ? ( + + + + + +

{tooltipText}

+
+
+ ) : null} + {showContext ? ( + + Context + + {context.sections.length} + + + ) : null} + +
+ ); +} + +function TranscriptItemRow({ + activeItemIds, + agentName, + compact, + item, + profiles, +}: { + activeItemIds: ReadonlySet; + agentName: string; + compact: boolean; + item: TranscriptItem; + profiles?: UserProfileLookup; +}) { + return ( +
+ {SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? ( + + ) : null} + +
+ ); +} + +function TurnSetupStatus({ + compact, + items, +}: { + compact: boolean; + items: Extract[]; +}) { + const timestamp = turnSetupTimestamp(items); + if (items.length === 0 || !timestamp) { + return null; + } + + return ( +
+ +
+ ); +} + function getItemSpacingClass(item: TranscriptItem) { if (item.type === "lifecycle") { return "mt-2 first:mt-0"; @@ -495,16 +843,25 @@ function ThoughtItem({ function MetadataItem({ compact, + embedded = false, item, }: { compact: boolean; + embedded?: boolean; item: Extract; }) { return (
diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index dda17b174..a5cd0c96b 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -3,6 +3,101 @@ import test from "node:test"; import { buildTranscript } from "./agentSessionTranscript.ts"; +const turnId = "turn-abc"; +const sessionId = "sess-1"; +const channelId = "channel-1"; +const baseTimestamp = "2026-06-14T22:20:23.000Z"; + +function makeTurnEvents() { + return [ + { + seq: 1, + timestamp: baseTimestamp, + kind: "turn_started", + agentIndex: 0, + channelId, + sessionId: null, + turnId, + payload: { triggeringEventIds: ["event-1"] }, + }, + { + seq: 2, + timestamp: baseTimestamp, + kind: "session_resolved", + agentIndex: 0, + channelId, + sessionId, + turnId, + payload: { sessionId, isNewSession: false }, + }, + { + seq: 3, + timestamp: baseTimestamp, + kind: "acp_write", + agentIndex: 0, + channelId, + sessionId, + turnId, + payload: { + method: "session/prompt", + params: { + sessionId, + prompt: [ + { + type: "text", + text: "[Buzz event: message]\nContent: @Ned deliberate, wider pass\nFrom: Tyler hex: abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd", + }, + ], + }, + }, + }, + { + seq: 4, + timestamp: "2026-06-14T22:20:47.000Z", + kind: "acp_read", + agentIndex: 0, + channelId, + sessionId, + turnId, + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + messageId: "msg-1", + content: [{ type: "text", text: "On it." }], + }, + }, + }, + }, + ]; +} + +test("buildTranscript attaches turnId and sessionId to generated items", () => { + const items = buildTranscript(makeTurnEvents()); + + assert.ok(items.length >= 4); + for (const item of items) { + assert.equal(item.turnId, turnId); + assert.equal(item.channelId, channelId); + } + + const sessionResolved = items.find( + (item) => + item.type === "lifecycle" && item.acpSource === "session_resolved", + ); + assert.equal(sessionResolved?.sessionId, sessionId); + + const userPrompt = items.find( + (item) => + item.type === "message" && + item.role === "user" && + item.acpSource === "session/prompt:user", + ); + assert.ok(userPrompt); + assert.equal(userPrompt.sessionId, sessionId); +}); + test("buildTranscript tags assistant chunks with agent_message_chunk", () => { const items = buildTranscript([ { diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 62d642f59..945945526 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -108,6 +108,12 @@ function sealOpenMessages(d: TranscriptDraft) { } } +type TranscriptItemContext = { + channelId: string | null; + turnId: string | null; + sessionId: string | null; +}; + function upsertMessage( d: TranscriptDraft, id: string, @@ -115,7 +121,7 @@ function upsertMessage( title: string, text: string, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, authorPubkey: string | null = null, acpSource?: string, ) { @@ -127,7 +133,9 @@ function upsertMessage( replaceItem(d, currentKey, { ...existing, text: existing.text + text, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, authorPubkey: authorPubkey ?? existing.authorPubkey, acpSource: acpSource ?? existing.acpSource, }); @@ -144,7 +152,9 @@ function upsertMessage( title, text, timestamp, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, authorPubkey, acpSource, }); @@ -159,7 +169,7 @@ function upsertTextItem( title: string, text: string, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, acpSource?: string, ) { const existing = d.itemsById.get(id); @@ -167,13 +177,25 @@ function upsertTextItem( replaceItem(d, id, { ...existing, text: existing.text + text, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, }); return; } sealOpenMessages(d); - pushItem(d, { id, type, title, text, timestamp, channelId, acpSource }); + pushItem(d, { + id, + type, + title, + text, + timestamp, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, + }); } function upsertMetadata( @@ -182,7 +204,7 @@ function upsertMetadata( title: string, sections: PromptSection[], timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, acpSource?: string, ) { const existing = d.itemsById.get(id); @@ -190,7 +212,9 @@ function upsertMetadata( replaceItem(d, id, { ...existing, sections, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, }); return; @@ -202,7 +226,9 @@ function upsertMetadata( title, sections, timestamp, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, acpSource, }); } @@ -218,7 +244,7 @@ function upsertTool( result: string, isError: boolean, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, acpSource?: string, ) { const existing = d.itemsById.get(id); @@ -248,7 +274,9 @@ function upsertTool( existing.completedAt == null ? timestamp : existing.completedAt, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, acpSource: acpSource ?? existing.acpSource, }); return; @@ -267,7 +295,9 @@ function upsertTool( timestamp, startedAt: timestamp, completedAt: null, - channelId, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, acpSource, }); } @@ -284,6 +314,11 @@ export function processTranscriptEvent( const channelId = event.channelId ?? null; const ch = channelId ?? "global"; + const ctx: TranscriptItemContext = { + channelId, + turnId: event.turnId, + sessionId: event.sessionId ?? d.latestSessionId, + }; if (event.kind === "turn_started") { upsertTextItem( @@ -293,7 +328,7 @@ export function processTranscriptEvent( "Turn started", describeTurnStarted(event.payload), event.timestamp, - channelId, + ctx, event.kind, ); } else if (event.kind === "session_resolved") { @@ -304,7 +339,7 @@ export function processTranscriptEvent( "Session ready", describeSessionResolved(event.payload), event.timestamp, - channelId, + ctx, event.kind, ); } else if (event.kind === "acp_parse_error") { @@ -315,7 +350,7 @@ export function processTranscriptEvent( "Wire parse error", extractBlockText(event.payload), event.timestamp, - channelId, + ctx, event.kind, ); } else if (event.kind === "turn_error" || event.kind === "agent_panic") { @@ -331,7 +366,7 @@ export function processTranscriptEvent( title, `${outcome}: ${error}`, event.timestamp, - channelId, + ctx, event.kind, ); } else if (event.kind === "acp_read" || event.kind === "acp_write") { @@ -350,7 +385,7 @@ export function processTranscriptEvent( parsedPrompt.userTitle, parsedPrompt.userText, event.timestamp, - channelId, + ctx, parsedPrompt.userPubkey, "session/prompt:user", ); @@ -362,7 +397,7 @@ export function processTranscriptEvent( "Prompt context", parsedPrompt.sections, event.timestamp, - channelId, + ctx, "session/prompt:context", ); } @@ -382,7 +417,7 @@ export function processTranscriptEvent( "Assistant", extractContentText(update.content), event.timestamp, - channelId, + ctx, null, updateType, ); @@ -394,7 +429,7 @@ export function processTranscriptEvent( "User", extractContentText(update.content), event.timestamp, - channelId, + ctx, null, updateType, ); @@ -406,7 +441,7 @@ export function processTranscriptEvent( "Thinking", extractContentText(update.content), event.timestamp, - channelId, + ctx, updateType, ); } else if (updateType === "tool_call") { @@ -423,7 +458,7 @@ export function processTranscriptEvent( extractToolResult(update), false, event.timestamp, - channelId, + ctx, updateType, ); } else if (updateType === "tool_call_update") { @@ -443,7 +478,7 @@ export function processTranscriptEvent( extractToolResult(update), status === "failed", event.timestamp, - channelId, + ctx, updateType, ); } else if (updateType === "plan") { @@ -454,7 +489,7 @@ export function processTranscriptEvent( "Plan", extractContentText(update.content) || JSON.stringify(update, null, 2), event.timestamp, - channelId, + ctx, updateType, ); } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs new file mode 100644 index 000000000..593c3104c --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildTranscriptDisplayBlocks, + flattenDisplayBlocks, + formatTurnSetupLabel, +} from "./agentSessionTranscriptGrouping.ts"; + +const baseTimestamp = "2026-06-14T22:20:23.000Z"; + +function lifecycle(id, title, acpSource, turnId, text = "") { + return { + id, + type: "lifecycle", + title, + text, + timestamp: baseTimestamp, + acpSource, + turnId, + sessionId: "sess-1", + channelId: "channel-1", + }; +} + +function userPrompt(id, text, turnId) { + return { + id, + type: "message", + role: "user", + title: "Buzz event", + text, + timestamp: baseTimestamp, + acpSource: "session/prompt:user", + turnId, + sessionId: "sess-1", + channelId: "channel-1", + }; +} + +function promptContext(id, turnId) { + return { + id, + type: "metadata", + title: "Prompt context", + sections: [{ title: "Channel", body: "general" }], + timestamp: baseTimestamp, + acpSource: "session/prompt:context", + turnId, + sessionId: "sess-1", + channelId: "channel-1", + }; +} + +function assistantMessage(id, text, turnId) { + return { + id, + type: "message", + role: "assistant", + title: "Assistant", + text, + timestamp: "2026-06-14T22:20:47.000Z", + acpSource: "agent_message_chunk", + turnId, + sessionId: "sess-1", + channelId: "channel-1", + }; +} + +function toolCall(id, turnId) { + return { + id, + type: "tool", + title: "Shell", + toolName: "buzz-dev-mcp__shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "ok", + isError: false, + timestamp: "2026-06-14T22:20:47.000Z", + startedAt: "2026-06-14T22:20:47.000Z", + completedAt: "2026-06-14T22:20:47.400Z", + acpSource: "tool_call_update", + turnId, + sessionId: "sess-1", + channelId: "channel-1", + }; +} + +test("buildTranscriptDisplayBlocks bundles user prompt, setup, and context together", () => { + const rawItems = [ + lifecycle( + "turn", + "Turn started", + "turn_started", + "turn-1", + "Triggered by 1 event.", + ), + lifecycle("session", "Session ready", "session_resolved", "turn-1"), + userPrompt("prompt", "@Ned deliberate, wider pass", "turn-1"), + promptContext("context", "turn-1"), + assistantMessage("assistant", "Thinking out loud.", "turn-1"), + toolCall("tool", "turn-1"), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id); + + assert.deepEqual(displayOrder, [ + "prompt", + "turn", + "session", + "context", + "assistant", + "tool", + ]); + + const turnBlock = blocks[0]; + assert.equal(turnBlock?.kind, "turn"); + assert.equal(turnBlock.segments[0]?.kind, "prompt"); + const promptSegment = turnBlock.segments[0]; + assert.equal(promptSegment.user.id, "prompt"); + assert.equal(promptSegment.context?.id, "context"); + assert.equal(promptSegment.setup.length, 2); + assert.equal(turnBlock.segments[1]?.kind, "item"); +}); + +test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundle", () => { + const rawItems = [ + lifecycle("turn", "Turn started", "turn_started", "turn-1"), + lifecycle("session", "Session ready", "session_resolved", "turn-1"), + userPrompt("prompt", "hello", "turn-1"), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + assert.equal(blocks.length, 1); + assert.equal(blocks[0]?.kind, "turn"); + + const turnBlock = blocks[0]; + assert.equal(turnBlock.segments.length, 1); + assert.equal(turnBlock.segments[0]?.kind, "prompt"); + assert.equal( + formatTurnSetupLabel(turnBlock.segments[0].setup), + "Turn started · Session ready", + ); +}); + +test("buildTranscriptDisplayBlocks keeps lifecycle visible when prompt is missing", () => { + const rawItems = [ + lifecycle("turn", "Turn started", "turn_started", "turn-1"), + lifecycle("session", "Session ready", "session_resolved", "turn-1"), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id); + + assert.deepEqual(displayOrder, ["turn", "session"]); +}); + +test("buildTranscriptDisplayBlocks leaves error lifecycle prominent outside prompt bundle", () => { + const rawItems = [ + lifecycle("turn", "Turn started", "turn_started", "turn-1"), + userPrompt("prompt", "hello", "turn-1"), + lifecycle( + "error", + "Turn error", + "turn_error", + "turn-1", + "timeout: agent hung", + ), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id); + + assert.deepEqual(displayOrder, ["prompt", "turn", "error"]); + assert.equal(blocks[0]?.segments[0]?.kind, "prompt"); + assert.equal(blocks[0]?.segments[1]?.kind, "item"); + assert.equal(blocks[0]?.segments[1]?.item.id, "error"); +}); + +test("buildTranscriptDisplayBlocks passes through items without turnId", () => { + const orphan = { + id: "orphan", + type: "lifecycle", + title: "Wire parse error", + text: "bad json", + timestamp: baseTimestamp, + acpSource: "acp_parse_error", + channelId: "channel-1", + }; + + const blocks = buildTranscriptDisplayBlocks([orphan]); + assert.equal(blocks.length, 1); + assert.equal(blocks[0]?.kind, "single"); + assert.equal(blocks[0]?.item.id, "orphan"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts new file mode 100644 index 000000000..96caa6d97 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -0,0 +1,208 @@ +import type { TranscriptItem } from "./agentSessionTypes"; + +export type TranscriptTurnSegment = + | { kind: "item"; item: TranscriptItem } + | { kind: "setup"; items: Extract[] } + | { + kind: "prompt"; + user: Extract; + context: Extract | null; + setup: Extract[]; + }; + +export type TranscriptDisplayBlock = + | { kind: "single"; item: TranscriptItem } + | { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] }; + +function isUserPrompt( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "message" && + item.role === "user" && + item.acpSource === "session/prompt:user" + ); +} + +function isPromptContext( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "metadata" && item.acpSource === "session/prompt:context" + ); +} + +function isSetupLifecycle( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "lifecycle" && + (item.acpSource === "turn_started" || item.acpSource === "session_resolved") + ); +} + +function isErrorLifecycle( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "lifecycle" && item.title.toLowerCase().includes("error") + ); +} + +type TurnBucket = { + turnId: string; + items: TranscriptItem[]; +}; + +function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { + const userPrompt = items.find(isUserPrompt) ?? null; + const setupLifecycle = items.filter(isSetupLifecycle); + const promptContext = items.find(isPromptContext) ?? null; + const consumed = new Set(); + + if (userPrompt) consumed.add(userPrompt); + for (const item of setupLifecycle) consumed.add(item); + if (promptContext) consumed.add(promptContext); + + const activity = items.filter((item) => !consumed.has(item)); + + if (!userPrompt) { + return items.map((item) => ({ kind: "item", item })); + } + + const segments: TranscriptTurnSegment[] = [ + { + kind: "prompt", + user: userPrompt, + context: promptContext, + setup: setupLifecycle, + }, + ]; + + for (const item of activity) { + if (isErrorLifecycle(item)) { + segments.push({ kind: "item", item }); + continue; + } + if (isSetupLifecycle(item)) { + continue; + } + segments.push({ kind: "item", item }); + } + + return segments; +} + +/** + * Build presentation-only display blocks from normalized transcript items. + * Raw observer order is preserved in the source items; this only reorders + * within a turn for user-facing narrative flow. + */ +export function buildTranscriptDisplayBlocks( + items: TranscriptItem[], +): TranscriptDisplayBlock[] { + const blocks: TranscriptDisplayBlock[] = []; + const turnBuckets = new Map(); + const displayOrder: Array< + { kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string } + > = []; + + for (const item of items) { + const turnId = item.turnId; + if (!turnId) { + displayOrder.push({ kind: "single", item }); + continue; + } + + let bucket = turnBuckets.get(turnId); + if (!bucket) { + bucket = { turnId, items: [] }; + turnBuckets.set(turnId, bucket); + displayOrder.push({ kind: "turn", turnId }); + } + bucket.items.push(item); + } + + for (const entry of displayOrder) { + if (entry.kind === "single") { + blocks.push({ kind: "single", item: entry.item }); + continue; + } + + const bucket = turnBuckets.get(entry.turnId); + if (!bucket || bucket.items.length === 0) { + continue; + } + + blocks.push({ + kind: "turn", + turnId: entry.turnId, + segments: classifyTurnItems(bucket.items), + }); + } + + return blocks; +} + +/** Flatten display blocks back to items for testing display order. */ +export function flattenDisplayBlocks( + blocks: TranscriptDisplayBlock[], +): TranscriptItem[] { + const result: TranscriptItem[] = []; + + for (const block of blocks) { + if (block.kind === "single") { + result.push(block.item); + continue; + } + + for (const segment of block.segments) { + if (segment.kind === "item") { + result.push(segment.item); + } else if (segment.kind === "prompt") { + result.push(segment.user); + result.push(...segment.setup); + if (segment.context) { + result.push(segment.context); + } + } else { + result.push(...segment.items); + } + } + } + + return result; +} + +/** Human-readable labels for a collapsed turn setup row. */ +export function formatTurnSetupLabel( + items: Extract[], +): string { + const labels = items.map((item) => item.title); + return labels.join(" · "); +} + +/** Earliest timestamp among setup lifecycle items. */ +export function turnSetupTimestamp( + items: Extract[], +): string | null { + if (items.length === 0) return null; + return items.reduce( + (earliest, item) => + Date.parse(item.timestamp) < Date.parse(earliest) + ? item.timestamp + : earliest, + items[0].timestamp, + ); +} + +/** Optional detail text from setup lifecycle items (e.g. trigger count). */ +export function turnSetupDetail( + items: Extract[], +): string | null { + const details = items + .map((item) => item.text.trim()) + .filter((text) => text.length > 0); + if (details.length === 0) return null; + return details.join(" "); +} diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 90fb2500e..c4061bb1d 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -23,8 +23,15 @@ export type ToolStatus = "executing" | "completed" | "failed" | "pending"; /** Observer/ACP wire label for dev-only transcript debugging. */ export type TranscriptAcpSource = string; +/** Shared optional identity fields attached during transcript construction. */ +export type TranscriptItemIdentity = { + turnId?: string | null; + sessionId?: string | null; + channelId?: string | null; +}; + export type TranscriptItem = - | { + | ({ id: string; type: "message"; role: "assistant" | "user"; @@ -33,36 +40,32 @@ export type TranscriptItem = timestamp: string; acpSource?: TranscriptAcpSource; authorPubkey?: string | null; - channelId?: string | null; - } - | { + } & TranscriptItemIdentity) + | ({ id: string; type: "thought"; title: string; text: string; timestamp: string; acpSource?: TranscriptAcpSource; - channelId?: string | null; - } - | { + } & TranscriptItemIdentity) + | ({ id: string; type: "lifecycle"; title: string; text: string; timestamp: string; acpSource?: TranscriptAcpSource; - channelId?: string | null; - } - | { + } & TranscriptItemIdentity) + | ({ id: string; type: "metadata"; title: string; sections: PromptSection[]; timestamp: string; acpSource?: TranscriptAcpSource; - channelId?: string | null; - } - | { + } & TranscriptItemIdentity) + | ({ id: string; type: "tool"; title: string; @@ -76,8 +79,7 @@ export type TranscriptItem = startedAt: string; completedAt: string | null; acpSource?: TranscriptAcpSource; - channelId?: string | null; - }; + } & TranscriptItemIdentity); export type PromptSection = { title: string;