diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 4a70be2e0..00eecd0d0 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -17,6 +17,7 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { Toggle } from "@/shared/ui/toggle"; +import { AnimatedCount } from "@/shared/ui/AnimatedCount"; import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; import type { PromptSection, TranscriptItem } from "./agentSessionTypes"; import { TurnLivenessIndicator } from "./TurnLivenessIndicator"; @@ -34,6 +35,7 @@ import { ActivityRowContent, ActivityRowLabel, type ActivityRowStats, + splitActivityRowCountedObject, splitActivityRowLabel, } from "./activityRenderClasses/ActivityRow"; import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp"; @@ -386,7 +388,9 @@ function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { return `turn:${turnId}:setup`; } if (segment.kind === "prompt") { - return `turn:${turnId}:prompt`; + // A turn can hold multiple prompt segments (initial prompt + mid-turn + // steers), so key on the user message id rather than the bare turn id. + return `turn:${turnId}:prompt:${segment.user.id}`; } if (segment.kind === "summary") { return segment.summary.id; @@ -455,10 +459,10 @@ function SameKindSummaryItem({ }) { const groupedFileEditDiffs = React.useMemo( () => - summary.renderClass === "file-edit" + summary.renderClass === "file-edit" || summary.variant === "mixed" ? getGroupedFileEditDiffs(summary.items) : [], - [summary.items, summary.renderClass], + [summary.items, summary.renderClass, summary.variant], ); const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs); const expandsToToolItems = summary.items.every( @@ -467,6 +471,10 @@ function SameKindSummaryItem({ const variant = useAgentSessionTranscriptVariant(); const timestampsEnabled = useTranscriptTimestampsEnabled(); const showTimestamp = timestampsEnabled && variant !== "compactPreview"; + // Mixed bursts expand to their child segments in original order: raw tool + // rows plus nested same-kind summaries that joined the burst (which stay + // expandable to their own child rows). + const childSegments = summary.segments ?? null; return ( <> @@ -483,30 +491,52 @@ function SameKindSummaryItem({ - {expandsToToolItems - ? summary.items.map((item) => ( - - )) - : summary.items.map((item) => ( -

- {item.type === "tool" - ? item.descriptor.preview || item.descriptor.label - : item.title} -

- ))} + {childSegments + ? childSegments.map((child) => + child.kind === "summary" ? ( + + ) : ( + + ), + ) + : expandsToToolItems + ? summary.items.map((item) => ( + + )) + : summary.items.map((item) => ( +

+ {item.type === "tool" + ? item.descriptor.preview || item.descriptor.label + : item.title} +

+ ))}
{showTimestamp ? ( @@ -550,15 +580,33 @@ function ToolRunSummaryLabel({ label: string; stats?: ActivityRowStats | null; }) { + const animationPreferenceEnabled = useTranscriptAnimationEnabled(); const parts = splitActivityRowLabel(label); if (!parts) { return {label}; } + // Streaming bursts grow their count in place ("Ran 16 tool calls" → + // "Ran 17 tool calls"); rolling the digits odometer-style makes the + // increment legible. AnimatedCount keeps an sr-only static value and + // falls back to static text under prefers-reduced-motion. + const countedObject = + animationPreferenceEnabled && typeof parts.object === "string" + ? splitActivityRowCountedObject(parts.object) + : null; + const object = countedObject ? ( + <> + + {countedObject.rest} + + ) : ( + parts.object + ); + return ( { + assert.deepEqual(splitActivityRowLabel("Ran 16 tool calls"), { + verb: "Ran", + object: "16 tool calls", + }); + assert.equal(splitActivityRowLabel("Thinking"), null); +}); + +test("splitActivityRowCountedObject splits a leading count", () => { + assert.deepEqual(splitActivityRowCountedObject("16 tool calls"), { + count: 16, + rest: " tool calls", + }); + assert.deepEqual(splitActivityRowCountedObject("3 files"), { + count: 3, + rest: " files", + }); + assert.deepEqual(splitActivityRowCountedObject("12 Buzz relay ops"), { + count: 12, + rest: " Buzz relay ops", + }); +}); + +test("splitActivityRowCountedObject leaves non-counted objects alone", () => { + assert.equal(splitActivityRowCountedObject("npm install"), null); + assert.equal(splitActivityRowCountedObject("2 "), null); + assert.equal(splitActivityRowCountedObject("16"), null); + assert.equal(splitActivityRowCountedObject("file 16"), null); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index 0b3fc8500..a40b4ed6d 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -1179,3 +1179,56 @@ test("observer feed renders system-prompt before prompt-context in display order "system-prompt item must have turnId=null", ); }); + +test("steer ingress bundles its prompt context into the steer prompt segment, not a standalone row", () => { + // The stray "Prompt context · 1 section" row was leaking from the goose + // session/steer path: it upserted metadata without an acpSource, so display + // grouping never consumed it into a prompt bundle. It must now ride behind + // the steer message bubble's checks-icon dialog like session/prompt context. + const events = [ + { + seq: 1, + timestamp: "2026-07-01T10:00:00.000Z", + kind: "acp_write", + agentIndex: 0, + channelId: "ch-1", + sessionId: "sess-1", + turnId: "turn-1", + payload: { + jsonrpc: "2.0", + id: 5, + method: "_goose/unstable/session/steer", + params: { + sessionId: "sess-1", + prompt: [ + { + type: "text", + text: `[Buzz event: @mention]\nEvent ID: ${"e".repeat(64)}\nFrom: x (hex: ${"f".repeat(64)})\nContent: steer me`, + }, + { type: "text", text: "[Thread context]\nPrior messages here." }, + ], + }, + }, + }, + ]; + + const rawItems = buildTranscript(events); + const steerMessage = rawItems.find((item) => item.type === "message"); + const steerContext = rawItems.find((item) => item.type === "metadata"); + assert.equal(steerMessage?.acpSource, "session/steer:user"); + assert.equal(steerContext?.acpSource, "session/steer:context"); + + const [block] = buildTranscriptDisplayBlocks(rawItems); + assert.equal(block.kind, "turn"); + const promptSegment = block.segments.find( + (segment) => segment.kind === "prompt", + ); + assert.ok(promptSegment, "expected steer message to render a prompt bundle"); + assert.equal(promptSegment.context?.id, steerContext.id); + assert.ok( + !block.segments.some( + (segment) => segment.kind === "item" && segment.item.type === "metadata", + ), + "steer context must not leak as a standalone metadata row", + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 720daa94c..dcb1ceeda 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -906,7 +906,7 @@ export function processTranscriptEvent( event.timestamp, ctx, parsedPrompt.userPubkey, - undefined, + "session/steer:user", parsedPrompt.userEventId, ); } @@ -918,6 +918,7 @@ export function processTranscriptEvent( parsedPrompt.sections, event.timestamp, ctx, + "session/steer:context", ); } } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 9c0bd30fc..599cc9244 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -285,8 +285,335 @@ test("buildTranscriptDisplayBlocks groups consecutive file edit tool runs", () = ); }); -test("buildTranscriptDisplayBlocks keeps non-contiguous same-kind runs expanded", () => { - const mkTool = (id, label, renderClass = "generic", groupKey = label) => ({ +test("buildTranscriptDisplayBlocks groups mixed consecutive eligible tool runs", () => { + const [block] = buildTranscriptDisplayBlocks([ + mkTool("read-1", "Read file", "file-read", "read_file"), + mkTool("shell-1", "Ran command", "shell", "shell:command"), + mkTool("read-2", "Read file", "file-read", "read_file"), + mkTool("read-3", "Read file", "file-read", "read_file"), + ]); + + assert.equal(block.kind, "turn"); + assert.equal(block.segments.length, 1); + assert.equal(block.segments[0].kind, "summary"); + assert.equal(block.segments[0].summary.variant, "mixed"); + assert.equal(block.segments[0].summary.label, "Ran 4 tool calls"); + assert.deepEqual( + block.segments[0].summary.items.map((item) => item.id), + ["read-1", "shell-1", "read-2", "read-3"], + ); +}); + +test("buildTranscriptDisplayBlocks groups tool bursts at threshold 2", () => { + const [block] = buildTranscriptDisplayBlocks([ + mkTool("read-1", "Read file", "file-read", "read_file"), + mkTool("shell-1", "Ran command", "shell", "shell:command"), + ]); + + assert.equal(block.kind, "turn"); + assert.equal(block.segments.length, 1); + assert.equal(block.segments[0].kind, "summary"); + assert.equal(block.segments[0].summary.variant, "mixed"); + assert.equal(block.segments[0].summary.label, "Ran 2 tool calls"); +}); + +test("buildTranscriptDisplayBlocks keeps a lone eligible tool row expanded", () => { + const [block] = buildTranscriptDisplayBlocks([ + mkTool("read-1", "Read file", "file-read", "read_file"), + ]); + + assert.equal(block.kind, "turn"); + assert.deepEqual( + block.segments.map((segment) => segment.kind), + ["item"], + ); +}); + +test("buildTranscriptDisplayBlocks nests same-kind summaries inside tool bursts", () => { + const [block] = buildTranscriptDisplayBlocks([ + mkTool("read-1", "Read file", "file-read", "read_file"), + mkTool("read-2", "Read file", "file-read", "read_file"), + mkTool("read-3", "Read file", "file-read", "read_file"), + mkTool("shell-1", "Ran command", "shell", "shell:command"), + mkTool("skill-1", "Read skill", "skill-read", "skill:load"), + ]); + + assert.equal(block.kind, "turn"); + assert.equal(block.segments.length, 1); + assert.equal(block.segments[0].kind, "summary"); + assert.equal(block.segments[0].summary.variant, "mixed"); + assert.equal(block.segments[0].summary.label, "Ran 5 tool calls"); + // Mixed summaries are the only visible burst summary; nested same-kind + // summaries flatten back to leaf rows to avoid redundant rows such as + // "Ran 16 tool calls" → "Ran 12 commands". + assert.deepEqual( + block.segments[0].summary.segments.map((child) => + child.kind === "item" ? child.item.id : child.summary.label, + ), + ["read-1", "read-2", "read-3", "shell-1", "skill-1"], + ); + // Flat leaf items preserve original order. + assert.deepEqual( + block.segments[0].summary.items.map((item) => item.id), + ["read-1", "read-2", "read-3", "shell-1", "skill-1"], + ); +}); + +test("buildTranscriptDisplayBlocks collapses alternating search/read bursts into one summary", () => { + const [block] = buildTranscriptDisplayBlocks([ + mkTool("shell-1", "Ran command", "shell", "shell:command"), + mkTool("read-1", "Read file", "file-read", "read_file"), + mkTool("read-2", "Read file", "file-read", "read_file"), + mkTool("read-3", "Read file", "file-read", "read_file"), + mkTool("shell-2", "Ran command", "shell", "shell:command"), + mkTool("read-4", "Read file", "file-read", "read_file"), + mkTool("read-5", "Read file", "file-read", "read_file"), + mkTool("read-6", "Read file", "file-read", "read_file"), + ]); + + assert.equal(block.kind, "turn"); + assert.equal(block.segments.length, 1); + assert.equal(block.segments[0].kind, "summary"); + assert.equal(block.segments[0].summary.variant, "mixed"); + assert.equal(block.segments[0].summary.label, "Ran 8 tool calls"); + assert.deepEqual( + block.segments[0].summary.segments.map((child) => + child.kind === "summary" ? child.summary.label : child.item.id, + ), + [ + "shell-1", + "read-1", + "read-2", + "read-3", + "shell-2", + "read-4", + "read-5", + "read-6", + ], + ); +}); + +test("buildTranscriptDisplayBlocks keeps messages out of mixed tool runs", () => { + const [block] = buildTranscriptDisplayBlocks([ + mkTool("read-1", "Read file", "file-read", "read_file"), + mkTool("shell-1", "Ran command", "shell", "shell:command"), + assistantMessage("assistant", "Here is what I found.", "turn-1"), + mkTool("read-2", "Read file", "file-read", "read_file"), + mkTool("shell-2", "Ran command", "shell", "shell:command"), + ]); + + assert.equal(block.kind, "turn"); + assert.deepEqual( + block.segments.map((segment) => segment.kind), + ["summary", "item", "summary"], + ); + assert.equal(block.segments[1].item.id, "assistant"); + assert.deepEqual( + block.segments[0].summary.items.map((item) => item.id), + ["read-1", "shell-1"], + ); + assert.deepEqual( + block.segments[2].summary.items.map((item) => item.id), + ["read-2", "shell-2"], + ); +}); + +test("buildTranscriptDisplayBlocks breaks failed tools out of mixed tool runs", () => { + const failed = { + ...mkTool("shell-fail", "Ran command failed", "error", "shell:command"), + isError: true, + }; + + const [block] = buildTranscriptDisplayBlocks([ + mkTool("read-1", "Read file", "file-read", "read_file"), + mkTool("shell-1", "Ran command", "shell", "shell:command"), + mkTool("skill-1", "Read skill", "skill-read", "skill:load"), + failed, + mkTool("read-2", "Read file", "file-read", "read_file"), + mkTool("shell-2", "Ran command", "shell", "shell:command"), + mkTool("image-1", "Viewed image", "image", "view_image"), + ]); + + assert.equal(block.kind, "turn"); + assert.deepEqual( + block.segments.map((segment) => segment.kind), + ["summary", "item", "summary"], + ); + assert.equal(block.segments[0].summary.variant, "mixed"); + assert.equal(block.segments[0].summary.label, "Ran 3 tool calls"); + assert.equal(block.segments[1].item.id, "shell-fail"); + assert.equal(block.segments[2].summary.variant, "mixed"); + assert.deepEqual( + block.segments[2].summary.items.map((item) => item.id), + ["read-2", "shell-2", "image-1"], + ); +}); + +test("flattenDisplayBlocks preserves child order through mixed summaries", () => { + const blocks = buildTranscriptDisplayBlocks([ + mkTool("read-1", "Read file", "file-read", "read_file"), + mkTool("shell-1", "Ran command", "shell", "shell:command"), + mkTool("edit-1", "Edited file", "file-edit", "file-edit:str_replace"), + ]); + + assert.deepEqual( + flattenDisplayBlocks(blocks).map((item) => item.id), + ["read-1", "shell-1", "edit-1"], + ); +}); + +test("buildTranscriptDisplayBlocks never same-kind groups failed tools", () => { + const mkFailed = (id) => ({ + ...mkTool(id, "Ran command failed", "error", "shell:command"), + isError: true, + }); + + const [block] = buildTranscriptDisplayBlocks([ + mkFailed("fail-1"), + mkFailed("fail-2"), + mkFailed("fail-3"), + ]); + + assert.equal(block.kind, "turn"); + assert.deepEqual( + block.segments.map((segment) => segment.kind), + ["item", "item", "item"], + ); +}); + +test("buildTranscriptDisplayBlocks never same-kind groups status tool rows", () => { + const [block] = buildTranscriptDisplayBlocks([ + mkTool("status-1", "Context compacted", "status", "status:post-compact"), + mkTool("status-2", "Context compacted", "status", "status:post-compact"), + mkTool("status-3", "Context compacted", "status", "status:post-compact"), + ]); + + assert.equal(block.kind, "turn"); + assert.deepEqual( + block.segments.map((segment) => segment.kind), + ["item", "item", "item"], + ); +}); + +test("buildTranscriptDisplayBlocks never same-kind groups suppressed tool rows", () => { + const [block] = buildTranscriptDisplayBlocks([ + mkTool("stop-1", "Checked todos", "suppressed", "suppressed:stop-hook"), + mkTool("stop-2", "Checked todos", "suppressed", "suppressed:stop-hook"), + mkTool("stop-3", "Checked todos", "suppressed", "suppressed:stop-hook"), + ]); + + assert.equal(block.kind, "turn"); + assert.deepEqual( + block.segments.map((segment) => segment.kind), + ["item", "item", "item"], + ); +}); + +test("buildTranscriptDisplayBlocks breaks same-kind runs on an ineligible row", () => { + const failed = { + ...mkTool("fail-1", "Read file failed", "error", "read_file"), + isError: true, + }; + + const [block] = buildTranscriptDisplayBlocks([ + mkTool("read-1", "Read file", "file-read", "read_file"), + mkTool("read-2", "Read file", "file-read", "read_file"), + failed, + mkTool("read-3", "Read file", "file-read", "read_file"), + mkTool("read-4", "Read file", "file-read", "read_file"), + ]); + + assert.equal(block.kind, "turn"); + assert.deepEqual( + block.segments.map((segment) => segment.kind), + ["summary", "item", "summary"], + ); + assert.equal(block.segments[1].item.id, "fail-1"); + assert.deepEqual( + block.segments[0].summary.items.map((item) => item.id), + ["read-1", "read-2"], + ); + assert.deepEqual( + block.segments[2].summary.items.map((item) => item.id), + ["read-3", "read-4"], + ); +}); + +test("buildTranscriptDisplayBlocks bundles steer message with steer context behind the prompt segment", () => { + const steerMessage = { + id: "steer:chan-1:turn-1", + type: "message", + role: "user", + title: "Buzz event", + text: "@Bart new steer instruction", + timestamp: baseTimestamp, + acpSource: "session/steer:user", + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + }; + const steerContext = { + id: "steer-context:chan-1:turn-1", + type: "metadata", + title: "Prompt context", + sections: [{ title: "Thread history", body: "prior messages" }], + timestamp: baseTimestamp, + acpSource: "session/steer:context", + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + }; + + const [block] = buildTranscriptDisplayBlocks([ + assistantMessage("assistant", "Working on it.", "turn-1"), + steerMessage, + steerContext, + toolCall("tool", "turn-1"), + ]); + + assert.equal(block.kind, "turn"); + assert.deepEqual( + block.segments.map((segment) => segment.kind), + ["item", "prompt", "item"], + ); + const steerSegment = block.segments[1]; + assert.equal(steerSegment.user.id, "steer:chan-1:turn-1"); + assert.equal(steerSegment.context?.id, "steer-context:chan-1:turn-1"); + assert.equal(steerSegment.systemPrompt, null); + assert.deepEqual(steerSegment.setup, []); + // No standalone "Prompt context" metadata row leaks into the feed. + assert.ok( + !block.segments.some( + (segment) => segment.kind === "item" && segment.item.type === "metadata", + ), + ); +}); + +test("buildTranscriptDisplayBlocks keeps orphan steer context visible when no steer message exists", () => { + const steerContext = { + id: "steer-context:chan-1:turn-1", + type: "metadata", + title: "Prompt context", + sections: [{ title: "Thread history", body: "prior messages" }], + timestamp: baseTimestamp, + acpSource: "session/steer:context", + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + }; + + const [block] = buildTranscriptDisplayBlocks([ + steerContext, + toolCall("tool", "turn-1"), + ]); + + assert.equal(block.kind, "turn"); + const flattened = flattenDisplayBlocks([block]).map((item) => item.id); + assert.ok(flattened.includes("steer-context:chan-1:turn-1")); +}); + +function mkTool(id, label, renderClass = "generic", groupKey = label) { + return { id, type: "tool", renderClass, @@ -310,18 +637,5 @@ test("buildTranscriptDisplayBlocks keeps non-contiguous same-kind runs expanded" turnId: "turn-1", sessionId: "sess-1", channelId: "chan-1", - }); - - const [block] = buildTranscriptDisplayBlocks([ - mkTool("read-1", "Read file", "file-read", "read_file"), - mkTool("shell-1", "Ran command", "shell", "shell:command"), - mkTool("read-2", "Read file", "file-read", "read_file"), - mkTool("read-3", "Read file", "file-read", "read_file"), - ]); - - assert.equal(block.kind, "turn"); - assert.deepEqual( - block.segments.map((segment) => segment.kind), - ["item", "item", "item", "item"], - ); -}); + }; +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index e9d0fb0ce..efbf1c5c2 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -3,7 +3,7 @@ import { classifyToolItem } from "./agentSessionToolClassifier"; export type TranscriptTurnSegment = | { kind: "item"; item: TranscriptItem } - | { kind: "summary"; summary: TranscriptSameKindSummary } + | { kind: "summary"; summary: TranscriptToolRunSummary } | { kind: "setup"; items: Extract[] } | { kind: "prompt"; @@ -17,12 +17,30 @@ export type TranscriptDisplayBlock = | { kind: "single"; item: TranscriptItem } | { kind: "turn"; turnId: string; segments: TranscriptTurnSegment[] }; -export type TranscriptSameKindSummary = { +export type TranscriptToolRunChildSegment = + | { kind: "item"; item: TranscriptItem } + | { kind: "summary"; summary: TranscriptToolRunSummary }; + +export type TranscriptToolRunSummary = { id: string; label: string; count: number; + /** Flat leaf tool items in original order (nested summaries expanded). */ items: TranscriptItem[]; renderClass: TranscriptItem["renderClass"] | null; + /** + * "same-kind" summaries collapse runs sharing one semantic groupKey and get + * specific labels ("Read 3 files"). "mixed" summaries collapse broader + * bursts of routine tool work ("Ran 9 tool calls") and may contain nested + * same-kind summaries as children. + */ + variant: "same-kind" | "mixed"; + /** + * Child segments in original order for mixed bursts — raw tool rows plus + * any same-kind summaries that joined the burst. Absent on same-kind + * summaries, whose children are just `items`. + */ + segments?: TranscriptToolRunChildSegment[]; timestamp: string; }; @@ -36,6 +54,16 @@ function isUserPrompt( ); } +function isSteerPrompt( + item: TranscriptItem, +): item is Extract { + return ( + item.type === "message" && + item.role === "user" && + item.acpSource === "session/steer:user" + ); +} + function isPromptContext( item: TranscriptItem, ): item is Extract { @@ -44,6 +72,12 @@ function isPromptContext( ); } +function isSteerContext( + item: TranscriptItem, +): item is Extract { + return item.type === "metadata" && item.acpSource === "session/steer:context"; +} + function isSystemPrompt( item: TranscriptItem, ): item is Extract { @@ -74,18 +108,41 @@ function classifyTurnItems( const userPrompt = items.find(isUserPrompt) ?? null; const setupLifecycle = items.filter(isSetupLifecycle); const promptContext = items.find(isPromptContext) ?? null; + // Steer context rides behind the steer message bubble's checks-icon dialog + // (same ingress treatment as session/prompt context) instead of rendering + // as a standalone "Prompt context" metadata row. + const steerContexts = items.filter(isSteerContext); const consumed = new Set(); if (userPrompt) consumed.add(userPrompt); for (const item of setupLifecycle) consumed.add(item); if (promptContext) consumed.add(promptContext); + for (const item of steerContexts) consumed.add(item); const activity = items.filter((item) => !consumed.has(item)); + const pendingSteerContexts = [...steerContexts]; + + const activitySegments: TranscriptTurnSegment[] = activity.map((item) => { + if (isSteerPrompt(item)) { + return { + kind: "prompt", + user: item, + systemPrompt: null, + context: pendingSteerContexts.shift() ?? null, + setup: [], + }; + } + return { kind: "item", item }; + }); + + // Steer context without a matched steer message keeps its standalone row so + // the metadata is never silently dropped. + for (const orphan of pendingSteerContexts) { + activitySegments.push({ kind: "item", item: orphan }); + } if (!userPrompt) { - return groupSameKindSegments( - activity.map((item) => ({ kind: "item", item })), - ); + return groupToolSegments(activitySegments); } const segments: TranscriptTurnSegment[] = [ @@ -96,13 +153,26 @@ function classifyTurnItems( context: promptContext, setup: setupLifecycle, }, + ...activitySegments, ]; - for (const item of activity) { - segments.push({ kind: "item", item }); - } + return groupToolSegments(segments); +} - return groupSameKindSegments(segments); +/** + * Two-pass tool grouping: + * 1. Same-kind runs collapse into summaries with specific labels + * ("Read 3 files", "Edited 2 files"). + * 2. Leftover adjacent eligible tool rows of differing kinds collapse into a + * mixed fallback summary ("Ran 5 tool calls"). + * + * Messages, errors, permissions, and status/lifecycle rows never join either + * pass, so intervention points stay visible. + */ +function groupToolSegments( + segments: TranscriptTurnSegment[], +): TranscriptTurnSegment[] { + return groupMixedToolRuns(groupSameKindSegments(segments)); } function groupSameKindSegments( @@ -137,6 +207,7 @@ function groupSameKindSegments( count: run.length, items: run, renderClass: getRenderClass(run[0]), + variant: "same-kind", timestamp: run[0].timestamp, }, }); @@ -149,14 +220,113 @@ function groupSameKindSegments( return grouped; } -function sameKindKey(item: TranscriptItem): string | null { - if (item.type !== "tool") return null; - const renderClass = getRenderClass(item); - if (renderClass === "message") { - return null; +const MIXED_RUN_MINIMUM_SEGMENTS = 2; + +/** + * Burst pass: collapse an interleave-tolerant run of routine tool work into + * one "Ran N tool calls" summary. Both leftover raw eligible tool rows and + * same-kind summaries produced by the first pass participate, so alternating + * patterns like search → read-summary → search → read-summary collapse into a + * single supervision row whose children are the original segments in order. + * Messages, permissions, errors/failed tools, and status/suppressed rows + * break bursts, so intervention points stay visible. + */ +function groupMixedToolRuns( + segments: TranscriptTurnSegment[], +): TranscriptTurnSegment[] { + const grouped: TranscriptTurnSegment[] = []; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + if (!isBurstParticipant(segment)) { + grouped.push(segment); + continue; + } + const run: TranscriptToolRunChildSegment[] = [segment]; + let j = i + 1; + while (j < segments.length) { + const next = segments[j]; + if (!isBurstParticipant(next)) break; + run.push(next); + j += 1; + } + if (run.length >= MIXED_RUN_MINIMUM_SEGMENTS) { + const items = run.flatMap((child) => + child.kind === "item" ? [child.item] : child.summary.items, + ); + // Mixed bursts are already the visual summary. Expanding nested + // same-kind summaries here creates redundant rows like + // "Ran 16 tool calls" → "Ran 12 commands". Keep same-kind summaries as + // grouping inputs, but flatten the mixed summary's visible children back + // to leaf tool rows. + const childSegments = items.map((item) => ({ + kind: "item" as const, + item, + })); + grouped.push({ + kind: "summary", + summary: { + id: `summary:mixed:${items[0].id}`, + label: `Ran ${items.length} tool calls`, + count: items.length, + items, + renderClass: null, + variant: "mixed", + segments: childSegments, + timestamp: items[0].timestamp, + }, + }); + } else { + grouped.push(...run); + } + i = j - 1; } + return grouped; +} + +/** + * Burst participants are raw eligible tool rows and same-kind summaries + * (already-collapsed routine tool work). Mixed summaries never re-enter. + */ +function isBurstParticipant( + segment: TranscriptTurnSegment, +): segment is TranscriptToolRunChildSegment { + if (segment.kind === "item") { + return isGroupingEligible(segment.item); + } + return segment.kind === "summary" && segment.summary.variant === "same-kind"; +} + +const GROUPING_ELIGIBLE_RENDER_CLASSES = new Set< + NonNullable +>([ + "file-read", + "skill-read", + "shell", + "relay-op", + "file-edit", + "image", + "plan", + "generic", +]); + +/** + * Shared eligibility for both grouping passes. Failed tools (isError or + * reclassified renderClass "error"), messages, permissions, status, and + * suppressed rows are never grouped and break runs, so intervention points + * stay visible. + */ +function isGroupingEligible(item: TranscriptItem): boolean { + if (item.type !== "tool" || item.isError) return false; + const renderClass = getRenderClass(item); + return ( + renderClass != null && GROUPING_ELIGIBLE_RENDER_CLASSES.has(renderClass) + ); +} + +function sameKindKey(item: TranscriptItem): string | null { + if (!isGroupingEligible(item) || item.type !== "tool") return null; const descriptor = item.descriptor ?? classifyToolItem(item); - return descriptor.groupKey ?? renderClass; + return descriptor.groupKey ?? getRenderClass(item); } function sameKindLabel(item: TranscriptItem, count: number): string { diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 9e0785b77..f0d2669b0 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -402,7 +402,7 @@ export function AgentSessionThreadPanel({