diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 9fc4b11110..683896cfd4 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -11,9 +11,11 @@ import { asRecord, formatCodeValue, formatDuration } from "./agentSessionUtils"; export function ToolItem({ compact = false, + grouped = false, item, }: { compact?: boolean; + grouped?: boolean; item: Extract; }) { const [isExpanded, setIsExpanded] = React.useState(false); @@ -32,7 +34,11 @@ export function ToolItem({ return (
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index dcd3fa9a12..cb4abf84fc 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -155,6 +155,14 @@ function TranscriptDisplayBlockView({ profiles?: UserProfileLookup; }) { if (block.kind === "single") { + if (block.item.type === "tool") { + return ( +
+ +
+ ); + } + return ( - {block.segments.map((segment) => ( - ( +
+ > + +
))}
); } +function getTurnSegmentSpacing( + previous: TranscriptTurnSegment | undefined, + segment: TranscriptTurnSegment, + compact: boolean, +): string | undefined { + if (!previous) { + return undefined; + } + + const involvesTool = + previous.kind === "tool_group" || segment.kind === "tool_group"; + if (involvesTool) { + return compact ? "mt-3" : "mt-3.5"; + } + + return compact ? "mt-2" : "mt-2.5"; +} + function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { if (segment.kind === "setup") { return `turn:${turnId}:setup`; @@ -195,6 +229,9 @@ function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { if (segment.kind === "prompt") { return `turn:${turnId}:prompt`; } + if (segment.kind === "tool_group") { + return `turn:${turnId}:tools:${segment.items.map((item) => item.id).join("+")}`; + } return segment.item.id; } @@ -226,12 +263,17 @@ function TranscriptTurnSegmentView({ return ; } + if (segment.kind === "tool_group") { + return ; + } + return ( @@ -252,10 +294,7 @@ function TurnPromptBlock({ user: Extract; }) { return ( -
+
{SHOW_TRANSCRIPT_ACP_SOURCE ? (
@@ -464,24 +503,42 @@ function TurnSetupFooter({ ); } +function ToolCallGroup({ + compact, + items, +}: { + compact: boolean; + items: Extract[]; +}) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + function TranscriptItemRow({ agentAvatarUrl, agentName, agentPubkey, compact, + embedded = false, item, profiles, }: AgentTranscriptIdentityProps & { compact: boolean; + embedded?: boolean; item: TranscriptItem; profiles?: UserProfileLookup; }) { return (
@@ -513,7 +570,7 @@ function TurnSetupStatus({ } return ( -
+
); @@ -521,10 +578,10 @@ function TurnSetupStatus({ function getItemSpacingClass(item: TranscriptItem) { if (item.type === "lifecycle") { - return "mt-2 first:mt-0"; + return "mt-1.5 first:mt-0"; } if (item.type === "metadata" || item.type === "thought") { - return "mt-2 first:mt-0"; + return "mt-1.5 first:mt-0"; } return undefined; } @@ -603,7 +660,13 @@ function MessageItem({ className={cn( "flex animate-in fade-in duration-200 motion-reduce:animate-none", isAssistant ? "flex-row" : "flex-row items-start justify-end", - compact ? "px-0 py-0.5" : "px-1 py-1", + isAssistant + ? compact + ? "px-0 py-0" + : "px-1 py-0" + : compact + ? "px-0 py-0.5" + : "px-1 py-1", )} data-role={isAssistant ? "assistant-message" : "user-message"} data-testid={ diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index d24100ffc1..7b416eb594 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -1,10 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - buildCompactToolSummary, - isCompactDeveloperTool, -} from "./agentSessionToolSummary.ts"; +import { buildCompactToolSummary } from "./agentSessionToolSummary.ts"; const baseTimestamp = "2026-06-14T19:00:00.000Z"; @@ -26,29 +23,19 @@ function makeTool(overrides = {}) { }; } -test("isCompactDeveloperTool returns false for Buzz relay tools", () => { - assert.equal( - isCompactDeveloperTool( - makeTool({ - toolName: "send_message", - buzzToolName: "send_message", - title: "Send Message", - }), - ), - false, +test("buildCompactToolSummary formats Buzz send_message preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "send_message", + buzzToolName: "send_message", + title: "Send Message", + args: { content: "Hello team" }, + }), ); -}); -test("isCompactDeveloperTool detects buzz-dev-mcp shell tools", () => { - assert.equal( - isCompactDeveloperTool( - makeTool({ - toolName: "buzz-dev-mcp__shell", - title: "buzz-dev-mcp__shell", - }), - ), - true, - ); + assert.equal(summary.kind, "buzz"); + assert.equal(summary.label, "Send Message"); + assert.equal(summary.preview, "Hello team"); }); test("buildCompactToolSummary formats shell command preview", () => { diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index 70aaccbbff..96b0244691 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -1,9 +1,15 @@ import type { ToolStatus, TranscriptItem } from "./agentSessionTypes"; import { + formatToolTitle, getBuzzToolInfo, + isGenericToolTitle, normalizeToolNameText, } from "./agentSessionToolCatalog"; -import { asRecord, getToolString } from "./agentSessionUtils"; +import { + asRecord, + getToolString, + getToolStringList, +} from "./agentSessionUtils"; export type CompactToolKind = | "shell" @@ -13,7 +19,9 @@ export type CompactToolKind = | "todo" | "stop_hook" | "post_compact_hook" - | "dev_mcp"; + | "dev_mcp" + | "buzz" + | "generic"; export type CompactToolSummary = { kind: CompactToolKind; @@ -35,26 +43,33 @@ const DEVELOPER_TOOL_BASES = new Set([ type ToolItem = Extract; -/** Whether this tool row should use the muted compact developer summary. */ -export function isCompactDeveloperTool(item: ToolItem): boolean { - if (item.buzzToolName && getBuzzToolInfo(item.buzzToolName)) { - return false; - } - return resolveDeveloperToolKind(item) !== null; -} - -/** Build the compact summary label and preview for developer MCP tool rows. */ +/** Build the muted compact summary label and preview for any tool row. */ export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { - const kind = resolveDeveloperToolKind(item) ?? "dev_mcp"; + const kind = resolveCompactToolKind(item); const { preview, thumbnailSrc } = extractCompactToolPreview(item, kind); return { kind, - label: compactToolLabel(kind, item.status, item.isError), + label: compactToolLabel(kind, item, item.status, item.isError), preview, thumbnailSrc, }; } +function resolveCompactToolKind(item: ToolItem): CompactToolKind { + const developerKind = resolveDeveloperToolKind(item); + if (developerKind) { + return developerKind; + } + + for (const value of [item.buzzToolName, item.toolName, item.title]) { + if (value && getBuzzToolInfo(value)) { + return "buzz"; + } + } + + return "generic"; +} + function resolveDeveloperToolKind(item: ToolItem): CompactToolKind | null { for (const value of [item.toolName, item.title, item.buzzToolName]) { const kind = classifyDeveloperToolName(value); @@ -98,16 +113,51 @@ function stripMcpServerPrefix(normalized: string): string { function compactToolLabel( kind: CompactToolKind, + item: ToolItem, status: ToolStatus, isError: boolean, ): string { const failed = isError || status === "failed"; const running = status === "executing" || status === "pending"; + if (kind === "buzz") { + const title = formatToolTitle( + item.buzzToolName ?? item.toolName, + item.title, + ); + if (failed) return `${title} failed`; + if (running) return title; + return title; + } + const labels: Record< CompactToolKind, { completed: string; running: string; failed: string } > = { + generic: { + completed: "Ran tool", + running: "Running tool", + failed: "Tool failed", + }, + ...developerToolLabels(), + }; + + const labelsMap = labels as Record< + CompactToolKind, + { completed: string; running: string; failed: string } + >; + + const set = labelsMap[kind]; + if (failed) return set.failed; + if (running) return set.running; + return set.completed; +} + +function developerToolLabels(): Record< + Exclude, + { completed: string; running: string; failed: string } +> { + return { shell: { completed: "Ran command", running: "Running command", @@ -149,11 +199,6 @@ function compactToolLabel( failed: "Tool failed", }, }; - - const set = labels[kind]; - if (failed) return set.failed; - if (running) return set.running; - return set.completed; } type CompactToolPreview = { @@ -181,13 +226,56 @@ function extractCompactToolPreview( case "post_compact_hook": return emptyPreview(); case "dev_mcp": + case "generic": return textPreview( - getToolString(args, ["command", "path", "source", "query", "name"]) ?? - null, + getToolString(args, [ + "command", + "path", + "source", + "query", + "name", + "content", + "message", + ]) ?? + (item.title && !isGenericToolTitle(item.title) ? item.title : null), ); + case "buzz": + return textPreview(extractBuzzToolPreview(args)); } } +function extractBuzzToolPreview(args: Record): string | null { + const content = getToolString(args, ["content", "message", "text", "body"]); + if (content) { + return content; + } + + const query = getToolString(args, ["query", "search"]); + if (query) { + return query; + } + + const channelId = getToolString(args, ["channel_id", "channelId"]); + if (channelId) { + return channelId; + } + + const workflowId = getToolString(args, ["workflow_id", "workflowId"]); + if (workflowId) { + return workflowId; + } + + const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]); + if (pubkeys.length === 1) { + return pubkeys[0]; + } + if (pubkeys.length > 1) { + return `${pubkeys.length} users`; + } + + return getToolString(args, ["event_id", "eventId", "name"]); +} + function textPreview(preview: string | null): CompactToolPreview { return { preview, thumbnailSrc: null }; } diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 83f1fb534d..07ba3b2e52 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -124,6 +124,32 @@ test("buildTranscriptDisplayBlocks bundles user prompt, setup, and context toget assert.equal(promptSegment.context?.id, "context"); assert.equal(promptSegment.setup.length, 2); assert.equal(turnBlock.segments[1]?.kind, "item"); + assert.equal(turnBlock.segments[2]?.kind, "tool_group"); +}); + +test("buildTranscriptDisplayBlocks groups consecutive tool calls", () => { + const rawItems = [ + userPrompt("prompt", "run these", "turn-1"), + toolCall("tool-1", "turn-1"), + toolCall("tool-2", "turn-1"), + assistantMessage("assistant", "Done.", "turn-1"), + toolCall("tool-3", "turn-1"), + ]; + + const blocks = buildTranscriptDisplayBlocks(rawItems); + const turnBlock = blocks[0]; + assert.equal(turnBlock?.kind, "turn"); + assert.equal(turnBlock.segments[1]?.kind, "tool_group"); + assert.deepEqual( + turnBlock.segments[1]?.items?.map((item) => item.id), + ["tool-1", "tool-2"], + ); + assert.equal(turnBlock.segments[2]?.kind, "item"); + assert.equal(turnBlock.segments[3]?.kind, "tool_group"); + assert.deepEqual( + turnBlock.segments[3]?.items?.map((item) => item.id), + ["tool-3"], + ); }); test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundle", () => { @@ -146,23 +172,25 @@ test("buildTranscriptDisplayBlocks collapses setup lifecycle inside prompt bundl ); }); -test("buildTranscriptDisplayBlocks hides setup lifecycle when prompt is missing", () => { +test("buildTranscriptDisplayBlocks hides setup and context when prompt is missing", () => { const rawItems = [ lifecycle("turn", "Turn started", "turn_started", "turn-1"), lifecycle("session", "Session ready", "session_resolved", "turn-1"), promptContext("context", "turn-1"), + toolCall("tool", "turn-1"), ]; const blocks = buildTranscriptDisplayBlocks(rawItems); const displayOrder = flattenDisplayBlocks(blocks).map((item) => item.id); - assert.deepEqual(displayOrder, ["context"]); + assert.deepEqual(displayOrder, ["tool"]); }); -test("buildTranscriptDisplayBlocks drops setup-only turns", () => { +test("buildTranscriptDisplayBlocks drops setup-and-context-only turns", () => { const rawItems = [ lifecycle("turn", "Turn started", "turn_started", "turn-1"), lifecycle("session", "Session ready", "session_resolved", "turn-1"), + promptContext("context", "turn-1"), ]; const blocks = buildTranscriptDisplayBlocks(rawItems); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 74fa75e5cd..3a840e22f1 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -2,6 +2,7 @@ import type { TranscriptItem } from "./agentSessionTypes"; export type TranscriptTurnSegment = | { kind: "item"; item: TranscriptItem } + | { kind: "tool_group"; items: Extract[] } | { kind: "setup"; items: Extract[] } | { kind: "prompt"; @@ -62,12 +63,14 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { if (userPrompt) consumed.add(userPrompt); for (const item of setupLifecycle) consumed.add(item); - if (userPrompt && promptContext) consumed.add(promptContext); + if (promptContext) consumed.add(promptContext); const activity = items.filter((item) => !consumed.has(item)); if (!userPrompt) { - return activity.map((item) => ({ kind: "item", item })); + return groupConsecutiveToolSegments( + activity.map((item) => ({ kind: "item", item })), + ); } const segments: TranscriptTurnSegment[] = [ @@ -90,7 +93,34 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { segments.push({ kind: "item", item }); } - return segments; + return groupConsecutiveToolSegments(segments); +} + +function groupConsecutiveToolSegments( + segments: TranscriptTurnSegment[], +): TranscriptTurnSegment[] { + const grouped: TranscriptTurnSegment[] = []; + let toolBuffer: Extract[] = []; + + const flushTools = () => { + if (toolBuffer.length === 0) { + return; + } + grouped.push({ kind: "tool_group", items: toolBuffer }); + toolBuffer = []; + }; + + for (const segment of segments) { + if (segment.kind === "item" && segment.item.type === "tool") { + toolBuffer.push(segment.item); + continue; + } + flushTools(); + grouped.push(segment); + } + + flushTools(); + return grouped; } /** @@ -162,6 +192,8 @@ export function flattenDisplayBlocks( for (const segment of block.segments) { if (segment.kind === "item") { result.push(segment.item); + } else if (segment.kind === "tool_group") { + result.push(...segment.items); } else if (segment.kind === "prompt") { result.push(segment.user); result.push(...segment.setup); diff --git a/desktop/src/shared/ui/toggle.tsx b/desktop/src/shared/ui/toggle.tsx index 939b2df16f..e6e9eac4fe 100644 --- a/desktop/src/shared/ui/toggle.tsx +++ b/desktop/src/shared/ui/toggle.tsx @@ -10,12 +10,14 @@ const toggleVariants = cva( variants: { variant: { default: "bg-transparent", + ghost: + "bg-transparent hover:bg-muted/70 hover:text-foreground data-[state=on]:bg-muted data-[state=on]:text-foreground", outline: "border border-input/40 bg-background hover:bg-muted/70 data-[state=on]:bg-muted data-[state=on]:text-foreground", }, size: { default: "h-9 px-3 min-w-9", - xs: "h-5 min-h-0 min-w-0 gap-1 rounded-md px-1.5 text-xs font-medium", + xs: "h-5 min-h-0 min-w-0 gap-1 rounded-md px-1.5 text-xs font-medium [&_svg]:size-3.5", sm: "h-8 px-2 min-w-8", lg: "h-10 px-3 min-w-10", },