From cb684ad315f4a90e706c8da23d3e25312f1dca2f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 22:24:05 -0700 Subject: [PATCH 01/20] feat(agents): improve activity transcript observability - Add transcript presentation model with Now summary, activity state, and live item highlighting - Support compact channel activity panel mode with intervention hint wiring - Refine tool, thought, metadata, and lifecycle row hierarchy for at-a-glance scanning - Share activity headline derivation with composer bot activity chip - Extend channel activity E2E coverage for Now summary and stop affordance --- .../agents/ui/AgentSessionToolItem.tsx | 28 +- .../agents/ui/AgentSessionTranscriptList.tsx | 373 ++++++++++++++++-- .../agents/ui/ManagedAgentSessionPanel.tsx | 18 + ...gentSessionTranscriptPresentation.test.mjs | 148 +++++++ .../ui/agentSessionTranscriptPresentation.ts | 280 +++++++++++++ .../channels/ui/AgentSessionThreadPanel.tsx | 3 + .../features/channels/ui/BotActivityBar.tsx | 15 +- desktop/tests/e2e/channels.spec.ts | 9 + 8 files changed, 830 insertions(+), 44 deletions(-) create mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 78dd98304..959a767d7 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ArrowUpRight, ChevronDown, Wrench } from "lucide-react"; +import { ArrowUpRight, ChevronDown, CircleDot, Wrench } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useUsersBatchQuery } from "@/features/profile/hooks"; @@ -7,6 +7,7 @@ import { resolveUserLabel } from "@/features/profile/lib/identity"; import type { Channel, UserProfileSummary } from "@/shared/api/types"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { TranscriptItem } from "./agentSessionTypes"; @@ -27,8 +28,12 @@ import { } from "./agentSessionUtils"; export function ToolItem({ + compact = false, + isActive = false, item, }: { + compact?: boolean; + isActive?: boolean; item: Extract; }) { const [isExpanded, setIsExpanded] = React.useState(false); @@ -48,7 +53,15 @@ export function ToolItem({ ); return ( -
+
) : null} {toolTitle} + {isActive ? ( + + + Live + + ) : null} {buzzTool ? ( ) : null} diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 2f1f47e1f..08bc730a3 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -1,32 +1,61 @@ import * as React from "react"; -import { Bot, Brain, ChevronDown, Radio, TerminalSquare } from "lucide-react"; +import { + AlertCircle, + Bot, + Brain, + ChevronDown, + CircleDot, + Loader2, + Radio, + TerminalSquare, + Wrench, +} from "lucide-react"; import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; 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 { UserAvatar } from "@/shared/ui/UserAvatar"; import type { TranscriptItem } from "./agentSessionTypes"; import { ToolItem } from "./AgentSessionToolItem"; +import { buildTranscriptPresentation } from "./agentSessionTranscriptPresentation"; import { formatTranscriptTime } from "./agentSessionUtils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; export function AgentSessionTranscriptList({ agentName, + compact = false, emptyDescription, + isWorking = false, items, profiles, + showInterventionHint = false, }: { agentName: string; + compact?: boolean; emptyDescription: string; + isWorking?: boolean; items: TranscriptItem[]; profiles?: UserProfileLookup; + showInterventionHint?: boolean; }) { + const presentation = React.useMemo( + () => buildTranscriptPresentation(items, isWorking), + [items, isWorking], + ); + if (items.length === 0) { return ( -
+

No ACP activity yet

{emptyDescription}

@@ -34,58 +63,296 @@ export function AgentSessionTranscriptList({ ); } + return ( +
+ +
+ {items.map((item) => ( +
+ +
+ ))} +
+
+ ); +} + +function TranscriptNowSummary({ + agentName, + compact, + isWorking, + presentation, + showInterventionHint, +}: { + agentName: string; + compact: boolean; + isWorking: boolean; + presentation: ReturnType; + showInterventionHint: boolean; +}) { + const { counts, hasError, headline, lastUpdatedAt, state } = presentation; + const showSummary = isWorking || hasError || itemsHaveActivity(counts); + + if (!showSummary) { + return null; + } + + const StateIcon = getStateIcon(state, isWorking); + const statusLabel = getStateLabel(state, isWorking); + const lastUpdated = lastUpdatedAt + ? formatTranscriptTime(lastUpdatedAt) + : null; + return (
- {items.map((item) => ( -
- + + + +
+
+

+ Now +

+ · +

{agentName}

+ {lastUpdated ? ( + <> + · +

+ {lastUpdated} +

+ + ) : null} +
+

+ {isWorking && state !== "idle" && state !== "error" ? ( + {headline} + ) : ( + headline + )} +

+
+ + {statusLabel} + + {counts.tools > 0 ? ( + 0 ? "error" : "default"} + /> + ) : null} + {counts.thoughts > 0 ? ( + + ) : null} + {counts.messages > 0 ? ( + + ) : null} +
+ {showInterventionHint && isWorking ? ( +

+ Use Stop{" "} + above to interrupt this turn without stopping the agent process. +

+ ) : null}
- ))} +
); } +function ActivityCountBadge({ + count, + label, + tone = "default", +}: { + count: number; + label: string; + tone?: "default" | "error"; +}) { + return ( + + {count} {label} + {count === 1 ? "" : "s"} + + ); +} + +function itemsHaveActivity( + counts: ReturnType["counts"], +) { + return ( + counts.tools > 0 || + counts.thoughts > 0 || + counts.messages > 0 || + counts.lifecycle > 0 + ); +} + +function getStateIcon( + state: ReturnType["state"], + isWorking: boolean, +) { + if (state === "error") { + return AlertCircle; + } + if (!isWorking) { + return CircleDot; + } + switch (state) { + case "tool_running": + return Wrench; + case "thinking": + return Brain; + case "responding": + return Bot; + default: + return Loader2; + } +} + +function getStateLabel( + state: ReturnType["state"], + isWorking: boolean, +) { + if (state === "error") { + return "Error"; + } + if (!isWorking) { + return "Idle"; + } + switch (state) { + case "tool_running": + return "Running tool"; + case "thinking": + return "Thinking"; + case "responding": + return "Responding"; + default: + return "Working"; + } +} + +function getItemSpacingClass(item: TranscriptItem) { + if (item.type === "lifecycle") { + return "mt-2 first:mt-0"; + } + if (item.type === "metadata" || item.type === "thought") { + return "mt-2 first:mt-0"; + } + return undefined; +} + const TranscriptItemView = React.memo(function TranscriptItemView({ agentName, + compact, + isActive, item, profiles, }: { agentName: string; + compact: boolean; + isActive: boolean; item: TranscriptItem; profiles?: UserProfileLookup; }) { if (item.type === "message") { return ( - + ); } if (item.type === "tool") { - return ; + return ; } if (item.type === "thought") { - return ; + return ; } if (item.type === "metadata") { - return ; + return ; } return ; }); function MessageItem({ agentName, + compact, + isActive, item, profiles, }: { agentName: string; + compact: boolean; + isActive: boolean; item: Extract; profiles?: UserProfileLookup; }) { @@ -105,9 +372,16 @@ function MessageItem({ return (
{!isAssistant ? ( {agentName} + {isActive ? ( + + + Live + + ) : null}
) : null} @@ -153,15 +436,35 @@ function MessageItem({ } function ThoughtItem({ + compact, + isActive, item, }: { + compact: boolean; + isActive: boolean; item: Extract; }) { return ( -
+
- + {item.title} + {isActive ? ( + + + Live + + ) : null} @@ -173,16 +476,24 @@ function ThoughtItem({ } function MetadataItem({ + compact, item, }: { + compact: boolean; item: Extract; }) { return ( -
+
- - {item.title} - + + {item.title} + {item.sections.length} section{item.sections.length === 1 ? "" : "s"} @@ -217,12 +528,20 @@ function LifecycleItem({ return (
+ {isError ? ( + + ) : ( + + )} {item.title} - {item.text ? - {item.text} : null} + {item.text ? · {item.text} : null}
); diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 3defb7d2b..efea6d336 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -28,8 +28,11 @@ type ManagedAgentSessionPanelProps = { agent: Pick; channelId?: string | null; className?: string; + compact?: boolean; emptyDescription?: string; + isWorking?: boolean; showHeader?: boolean; + showInterventionHint?: boolean; showRaw?: boolean; profiles?: UserProfileLookup; }; @@ -38,8 +41,11 @@ export function ManagedAgentSessionPanel({ agent, channelId = null, className, + compact = false, emptyDescription = "Mention this agent in a channel to watch the next turn.", + isWorking = false, showHeader = true, + showInterventionHint = false, showRaw = true, profiles, }: ManagedAgentSessionPanelProps) { @@ -94,12 +100,15 @@ export function ManagedAgentSessionPanel({ @@ -144,22 +153,28 @@ function SessionHeader({ function SessionBody({ agentName, + compact, connectionState, emptyDescription, errorMessage, events, hasObserver, + isWorking, profiles, + showInterventionHint, showRaw, transcript, }: { agentName: string; + compact: boolean; connectionState: ConnectionState; emptyDescription: string; errorMessage: string | null; events: ObserverEvent[]; hasObserver: boolean; + isWorking: boolean; profiles?: UserProfileLookup; + showInterventionHint: boolean; showRaw: boolean; transcript: TranscriptItem[]; }) { @@ -179,9 +194,12 @@ function SessionBody({ > {showRaw ? : null}
diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs new file mode 100644 index 000000000..a13736189 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildTranscriptPresentation, + getActivityHeadline, + isMeaningfulItem, +} from "./agentSessionTranscriptPresentation.ts"; + +const baseTimestamp = "2026-06-14T19:00:00.000Z"; + +function makeTool(overrides = {}) { + return { + id: "tool:1", + type: "tool", + title: "Send Message", + toolName: "send_message", + buzzToolName: "send_message", + status: "executing", + args: { channel_id: "abc" }, + result: "", + isError: false, + timestamp: baseTimestamp, + startedAt: baseTimestamp, + completedAt: null, + ...overrides, + }; +} + +function makeMessage(overrides = {}) { + return { + id: "msg:1", + type: "message", + role: "assistant", + title: "Assistant", + text: "Looking into that now.", + timestamp: baseTimestamp, + ...overrides, + }; +} + +test("getActivityHeadline formats tool titles and assistant text", () => { + assert.equal(getActivityHeadline(makeTool()), "Send Message"); + assert.equal( + getActivityHeadline(makeMessage({ text: "First line\nSecond line" })), + "First line", + ); + assert.equal(getActivityHeadline(makeMessage({ text: " " })), "Responding"); +}); + +test("isMeaningfulItem ignores lifecycle noise and metadata", () => { + assert.equal( + isMeaningfulItem({ + id: "life:1", + type: "lifecycle", + title: "Turn started", + text: "", + timestamp: baseTimestamp, + }), + false, + ); + assert.equal( + isMeaningfulItem({ + id: "meta:1", + type: "metadata", + title: "Prompt context", + sections: [], + timestamp: baseTimestamp, + }), + false, + ); + assert.equal( + isMeaningfulItem({ + id: "life:2", + type: "lifecycle", + title: "Turn error", + text: "boom", + timestamp: baseTimestamp, + }), + true, + ); +}); + +test("buildTranscriptPresentation marks running tools as active while working", () => { + const items = [ + makeMessage({ id: "msg:user", role: "user", text: "Please help" }), + makeTool({ id: "tool:running", status: "executing" }), + ]; + + const presentation = buildTranscriptPresentation(items, true); + + assert.equal(presentation.state, "tool_running"); + assert.equal(presentation.headline, "Send Message"); + assert.equal(presentation.counts.tools, 1); + assert.equal(presentation.counts.messages, 1); + assert.ok(presentation.activeItemIds.has("tool:running")); +}); + +test("buildTranscriptPresentation highlights assistant streaming while working", () => { + const items = [ + makeMessage({ id: "msg:assistant", role: "assistant", text: "Drafting" }), + ]; + + const presentation = buildTranscriptPresentation(items, true); + + assert.equal(presentation.state, "responding"); + assert.equal(presentation.headline, "Drafting"); + assert.ok(presentation.activeItemIds.has("msg:assistant")); +}); + +test("buildTranscriptPresentation surfaces lifecycle errors", () => { + const items = [ + makeTool({ + id: "tool:done", + status: "completed", + completedAt: "2026-06-14T19:00:05.000Z", + }), + { + id: "life:error", + type: "lifecycle", + title: "Turn error", + text: "timeout", + timestamp: "2026-06-14T19:00:06.000Z", + }, + ]; + + const presentation = buildTranscriptPresentation(items, false); + + assert.equal(presentation.state, "error"); + assert.equal(presentation.hasError, true); + assert.equal(presentation.headline, "Turn error"); +}); + +test("buildTranscriptPresentation returns idle state when not working", () => { + const items = [ + makeTool({ + id: "tool:done", + status: "completed", + completedAt: "2026-06-14T19:00:05.000Z", + }), + ]; + + const presentation = buildTranscriptPresentation(items, false); + + assert.equal(presentation.state, "idle"); + assert.equal(presentation.activeItemIds.size, 0); + assert.equal(presentation.headline, "Send Message"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts new file mode 100644 index 000000000..bb6fd4c9f --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -0,0 +1,280 @@ +import { formatToolTitle } from "./agentSessionToolCatalog"; +import type { TranscriptItem } from "./agentSessionTypes"; + +export type TranscriptActivityCounts = { + tools: number; + toolErrors: number; + thoughts: number; + messages: number; + lifecycle: number; + metadata: number; +}; + +export type TranscriptActivityState = + | "idle" + | "responding" + | "thinking" + | "tool_running" + | "error"; + +export type TranscriptPresentation = { + headline: string; + state: TranscriptActivityState; + counts: TranscriptActivityCounts; + latestMeaningfulItem: TranscriptItem | null; + latestMeaningfulItemId: string | null; + activeItemIds: ReadonlySet; + lastUpdatedAt: string | null; + hasError: boolean; +}; + +const LIFECYCLE_NOISE = new Set([ + "turn started", + "session ready", + "wire parse error", +]); + +/** Human-readable headline for a single transcript item. */ +export function getActivityHeadline(item: TranscriptItem): string | null { + if (item.type === "tool") { + return formatToolTitle(item.buzzToolName ?? item.toolName, item.title); + } + + if (item.type === "message") { + if (item.role === "assistant") { + const trimmed = item.text.trim(); + if (trimmed.length > 0) { + const firstLine = trimmed.split("\n")[0]?.trim() ?? ""; + if (firstLine.length > 0) { + return firstLine.length > 72 + ? `${firstLine.slice(0, 69)}…` + : firstLine; + } + } + return "Responding"; + } + return item.title || "User prompt"; + } + + if (item.type === "thought") { + return item.title === "Plan" ? "Planning" : item.title; + } + + if (item.type === "metadata") { + return item.title; + } + + return item.title; +} + +function isLifecycleNoise( + item: Extract, +) { + return LIFECYCLE_NOISE.has(item.title.toLowerCase()); +} + +/** Whether an item should contribute to the "Now" summary and headline scan. */ +export function isMeaningfulItem(item: TranscriptItem): boolean { + if (item.type === "lifecycle") { + return !isLifecycleNoise(item); + } + if (item.type === "metadata") { + return false; + } + return true; +} + +function isToolRunning(item: Extract) { + return item.status === "executing" || item.status === "pending"; +} + +function isLifecycleError( + item: Extract, +) { + return item.title.toLowerCase().includes("error"); +} + +function countItems(items: TranscriptItem[]): TranscriptActivityCounts { + const counts: TranscriptActivityCounts = { + tools: 0, + toolErrors: 0, + thoughts: 0, + messages: 0, + lifecycle: 0, + metadata: 0, + }; + + for (const item of items) { + switch (item.type) { + case "tool": + counts.tools += 1; + if (item.isError || item.status === "failed") { + counts.toolErrors += 1; + } + break; + case "thought": + counts.thoughts += 1; + break; + case "message": + counts.messages += 1; + break; + case "lifecycle": + counts.lifecycle += 1; + break; + case "metadata": + counts.metadata += 1; + break; + } + } + + return counts; +} + +function findLatestMeaningfulItem( + items: TranscriptItem[], +): TranscriptItem | null { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (isMeaningfulItem(item)) { + return item; + } + } + return null; +} + +function resolveActivityState( + latest: TranscriptItem | null, + hasError: boolean, + isWorking: boolean, +): TranscriptActivityState { + if (!isWorking) { + return hasError ? "error" : "idle"; + } + + if (hasError && latest?.type === "lifecycle" && isLifecycleError(latest)) { + return "error"; + } + + if (latest?.type === "tool" && isToolRunning(latest)) { + return "tool_running"; + } + + if (latest?.type === "thought") { + return "thinking"; + } + + if (latest?.type === "message" && latest.role === "assistant") { + return "responding"; + } + + if (latest?.type === "tool") { + return "tool_running"; + } + + return "idle"; +} + +function resolveHeadline( + latest: TranscriptItem | null, + state: TranscriptActivityState, + isWorking: boolean, +): string { + if (latest) { + const headline = getActivityHeadline(latest); + if (headline) { + return headline; + } + } + + if (isWorking) { + switch (state) { + case "tool_running": + return "Running a tool"; + case "thinking": + return "Thinking"; + case "responding": + return "Responding"; + case "error": + return "Encountered an error"; + default: + return "Working"; + } + } + + if (state === "error") { + return "Last turn ended with an error"; + } + + return "Waiting for activity"; +} + +function collectActiveItemIds( + items: TranscriptItem[], + isWorking: boolean, +): ReadonlySet { + if (!isWorking || items.length === 0) { + return new Set(); + } + + const active = new Set(); + + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + + if (item.type === "tool" && isToolRunning(item)) { + active.add(item.id); + break; + } + + if (item.type === "thought") { + active.add(item.id); + break; + } + + if (item.type === "message" && item.role === "assistant") { + active.add(item.id); + break; + } + } + + return active; +} + +function detectError(items: TranscriptItem[]): boolean { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (!isMeaningfulItem(item)) { + continue; + } + if (item.type === "lifecycle" && isLifecycleError(item)) { + return true; + } + if (item.type === "tool" && (item.isError || item.status === "failed")) { + return true; + } + break; + } + return false; +} + +/** Derive presentation metadata for a transcript list. */ +export function buildTranscriptPresentation( + items: TranscriptItem[], + isWorking = false, +): TranscriptPresentation { + const latestMeaningfulItem = findLatestMeaningfulItem(items); + const hasError = detectError(items); + const state = resolveActivityState(latestMeaningfulItem, hasError, isWorking); + + return { + headline: resolveHeadline(latestMeaningfulItem, state, isWorking), + state, + counts: countItems(items), + latestMeaningfulItem, + latestMeaningfulItemId: latestMeaningfulItem?.id ?? null, + activeItemIds: collectActiveItemIds(items, isWorking), + lastUpdatedAt: + items.length > 0 ? (items[items.length - 1]?.timestamp ?? null) : null, + hasError, + }; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index ae633bbf3..e366fe1fe 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -160,9 +160,12 @@ export function AgentSessionThreadPanel({ agent={agent} channelId={channel.id} className="border-0 bg-transparent p-0 shadow-none" + compact emptyDescription={`Mention ${agent.name} in the channel to see its work here.`} + isWorking={isWorking} profiles={profiles} showHeader={false} + showInterventionHint={canInterruptTurn} showRaw={false} />
diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index a289200ad..5e56422e1 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -2,8 +2,7 @@ import * as React from "react"; import { Loader2 } from "lucide-react"; import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; -import type { TranscriptItem } from "@/features/agents/ui/agentSessionTypes"; -import { formatToolTitle } from "@/features/agents/ui/agentSessionToolCatalog"; +import { getActivityHeadline } from "@/features/agents/ui/agentSessionTranscriptPresentation"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -27,18 +26,6 @@ const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; const HEADLINE_ROTATION_MS = 2200; -function getActivityHeadline(item: TranscriptItem): string | null { - if (item.type === "tool") { - return formatToolTitle(item.buzzToolName ?? item.toolName, item.title); - } - - if (item.type === "message") { - return item.role === "assistant" ? "Responding" : item.title; - } - - return item.title; -} - export function BotActivityComposerAction({ agents, channelId = null, diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 6f4f53327..cb77d8a42 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -773,6 +773,15 @@ test("shows and clears activity indicators for active channel agents", async ({ await expect(page.getByTestId("agent-session-thread-panel")).toContainText( "alice", ); + await expect(page.getByTestId("agent-transcript-now-summary")).toBeVisible(); + await expect(page.getByTestId("agent-transcript-now-summary")).toContainText( + "Working", + ); + await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible(); + await expect(page.getByTestId("agent-session-stop-turn")).toBeDisabled(); + await expect(page.getByTestId("agent-session-thread-panel")).toContainText( + "No ACP activity yet", + ); await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0); await page.evaluate((pubkey) => { From b98df613592d124c6dba1451003fd1ae32602024 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 22:24:42 -0700 Subject: [PATCH 02/20] chore(agents): add dev-only ACP source labels on transcript rows - Stamp each transcript item with its observer/ACP wire source during build - Show amber dev-only badges in the activity transcript for local debugging - Add unit tests covering agent_message_chunk vs agent_thought_chunk tagging --- .../agents/ui/AgentSessionTranscriptList.tsx | 18 ++++++ .../agents/ui/agentSessionTranscript.test.mjs | 60 +++++++++++++++++++ .../agents/ui/agentSessionTranscript.ts | 48 +++++++++++++-- .../features/agents/ui/agentSessionTypes.ts | 8 +++ 4 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 desktop/src/features/agents/ui/agentSessionTranscript.test.mjs diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 08bc730a3..019b4d7d9 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -26,6 +26,9 @@ import { buildTranscriptPresentation } from "./agentSessionTranscriptPresentatio import { formatTranscriptTime } from "./agentSessionUtils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +/** Dev-only: surface the observer wire label that produced each transcript row. */ +const SHOW_TRANSCRIPT_ACP_SOURCE = import.meta.env.DEV; + export function AgentSessionTranscriptList({ agentName, compact = false, @@ -87,6 +90,9 @@ export function AgentSessionTranscriptList({ )} key={item.id} > + {SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? ( + + ) : null} + {source} + + ); +} + function TranscriptNowSummary({ agentName, compact, diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs new file mode 100644 index 000000000..dda17b174 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildTranscript } from "./agentSessionTranscript.ts"; + +test("buildTranscript tags assistant chunks with agent_message_chunk", () => { + const items = buildTranscript([ + { + seq: 1, + timestamp: "2026-06-14T20:47:14.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "channel-1", + sessionId: "sess-1", + turnId: "turn-1", + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + messageId: "msg-1", + content: [{ type: "text", text: "Marge is summoned." }], + }, + }, + }, + }, + ]); + + assert.equal(items.length, 1); + assert.equal(items[0]?.type, "message"); + assert.equal(items[0]?.acpSource, "agent_message_chunk"); +}); + +test("buildTranscript tags thought chunks with agent_thought_chunk", () => { + const items = buildTranscript([ + { + seq: 2, + timestamp: "2026-06-14T20:47:15.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "channel-1", + sessionId: "sess-1", + turnId: "turn-1", + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_thought_chunk", + messageId: "thought-1", + content: [{ type: "text", text: "Considering next step." }], + }, + }, + }, + }, + ]); + + assert.equal(items.length, 1); + assert.equal(items[0]?.type, "thought"); + assert.equal(items[0]?.acpSource, "agent_thought_chunk"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 60cf7f62b..62d642f59 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -117,6 +117,7 @@ function upsertMessage( timestamp: string, channelId: string | null, authorPubkey: string | null = null, + acpSource?: string, ) { const currentKey = d.activeMessageKey.get(id); @@ -128,6 +129,7 @@ function upsertMessage( text: existing.text + text, channelId, authorPubkey: authorPubkey ?? existing.authorPubkey, + acpSource: acpSource ?? existing.acpSource, }); return; } @@ -144,6 +146,7 @@ function upsertMessage( timestamp, channelId, authorPubkey, + acpSource, }); d.activeMessageKey = new Map(d.activeMessageKey); d.activeMessageKey.set(id, newKey); @@ -157,14 +160,20 @@ function upsertTextItem( text: string, timestamp: string, channelId: string | null, + acpSource?: string, ) { const existing = d.itemsById.get(id); if (existing && existing.type === type) { - replaceItem(d, id, { ...existing, text: existing.text + text, channelId }); + replaceItem(d, id, { + ...existing, + text: existing.text + text, + channelId, + acpSource: acpSource ?? existing.acpSource, + }); return; } sealOpenMessages(d); - pushItem(d, { id, type, title, text, timestamp, channelId }); + pushItem(d, { id, type, title, text, timestamp, channelId, acpSource }); } function upsertMetadata( @@ -174,14 +183,28 @@ function upsertMetadata( sections: PromptSection[], timestamp: string, channelId: string | null, + acpSource?: string, ) { const existing = d.itemsById.get(id); if (existing?.type === "metadata") { - replaceItem(d, id, { ...existing, sections, channelId }); + replaceItem(d, id, { + ...existing, + sections, + channelId, + acpSource: acpSource ?? existing.acpSource, + }); return; } sealOpenMessages(d); - pushItem(d, { id, type: "metadata", title, sections, timestamp, channelId }); + pushItem(d, { + id, + type: "metadata", + title, + sections, + timestamp, + channelId, + acpSource, + }); } function upsertTool( @@ -196,6 +219,7 @@ function upsertTool( isError: boolean, timestamp: string, channelId: string | null, + acpSource?: string, ) { const existing = d.itemsById.get(id); const canonicalBuzzToolName = @@ -225,6 +249,7 @@ function upsertTool( ? timestamp : existing.completedAt, channelId, + acpSource: acpSource ?? existing.acpSource, }); return; } @@ -243,6 +268,7 @@ function upsertTool( startedAt: timestamp, completedAt: null, channelId, + acpSource, }); } @@ -268,6 +294,7 @@ export function processTranscriptEvent( describeTurnStarted(event.payload), event.timestamp, channelId, + event.kind, ); } else if (event.kind === "session_resolved") { upsertTextItem( @@ -278,6 +305,7 @@ export function processTranscriptEvent( describeSessionResolved(event.payload), event.timestamp, channelId, + event.kind, ); } else if (event.kind === "acp_parse_error") { upsertTextItem( @@ -288,6 +316,7 @@ export function processTranscriptEvent( extractBlockText(event.payload), event.timestamp, channelId, + event.kind, ); } else if (event.kind === "turn_error" || event.kind === "agent_panic") { const payload = asRecord(event.payload); @@ -303,6 +332,7 @@ export function processTranscriptEvent( `${outcome}: ${error}`, event.timestamp, channelId, + event.kind, ); } else if (event.kind === "acp_read" || event.kind === "acp_write") { const payload = asRecord(event.payload); @@ -322,6 +352,7 @@ export function processTranscriptEvent( event.timestamp, channelId, parsedPrompt.userPubkey, + "session/prompt:user", ); } if (parsedPrompt.sections.length > 0) { @@ -332,6 +363,7 @@ export function processTranscriptEvent( parsedPrompt.sections, event.timestamp, channelId, + "session/prompt:context", ); } } @@ -351,6 +383,8 @@ export function processTranscriptEvent( extractContentText(update.content), event.timestamp, channelId, + null, + updateType, ); } else if (updateType === "user_message_chunk") { upsertMessage( @@ -361,6 +395,8 @@ export function processTranscriptEvent( extractContentText(update.content), event.timestamp, channelId, + null, + updateType, ); } else if (updateType === "agent_thought_chunk") { upsertTextItem( @@ -371,6 +407,7 @@ export function processTranscriptEvent( extractContentText(update.content), event.timestamp, channelId, + updateType, ); } else if (updateType === "tool_call") { const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`; @@ -387,6 +424,7 @@ export function processTranscriptEvent( false, event.timestamp, channelId, + updateType, ); } else if (updateType === "tool_call_update") { const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`; @@ -406,6 +444,7 @@ export function processTranscriptEvent( status === "failed", event.timestamp, channelId, + updateType, ); } else if (updateType === "plan") { upsertTextItem( @@ -416,6 +455,7 @@ export function processTranscriptEvent( extractContentText(update.content) || JSON.stringify(update, null, 2), event.timestamp, channelId, + updateType, ); } } diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 2ff4ea305..90fb2500e 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -20,6 +20,9 @@ export type ConnectionState = export type ToolStatus = "executing" | "completed" | "failed" | "pending"; +/** Observer/ACP wire label for dev-only transcript debugging. */ +export type TranscriptAcpSource = string; + export type TranscriptItem = | { id: string; @@ -28,6 +31,7 @@ export type TranscriptItem = title: string; text: string; timestamp: string; + acpSource?: TranscriptAcpSource; authorPubkey?: string | null; channelId?: string | null; } @@ -37,6 +41,7 @@ export type TranscriptItem = title: string; text: string; timestamp: string; + acpSource?: TranscriptAcpSource; channelId?: string | null; } | { @@ -45,6 +50,7 @@ export type TranscriptItem = title: string; text: string; timestamp: string; + acpSource?: TranscriptAcpSource; channelId?: string | null; } | { @@ -53,6 +59,7 @@ export type TranscriptItem = title: string; sections: PromptSection[]; timestamp: string; + acpSource?: TranscriptAcpSource; channelId?: string | null; } | { @@ -68,6 +75,7 @@ export type TranscriptItem = timestamp: string; startedAt: string; completedAt: string | null; + acpSource?: TranscriptAcpSource; channelId?: string | null; }; From a99a835e1e5c6c6467d47e52a9a81a886c55a1b3 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 23:21:05 -0700 Subject: [PATCH 03/20] feat(agents): add raw ACP activity view toggle - Add a Raw switch to the agent activity panel header so users can swap between the formatted transcript and raw ACP JSON-RPC payloads. - Update the activity title to reflect raw mode and render the raw feed as the full panel content instead of a nested dark card. - Keep the existing transcript view unchanged by default and scope the raw-mode reset to agent/channel changes. --- .../agents/ui/ManagedAgentSessionPanel.tsx | 26 +++++++++- .../src/features/agents/ui/RawEventRail.tsx | 44 +++++----------- .../channels/ui/AgentSessionThreadPanel.tsx | 50 +++++++++++++++++-- 3 files changed, 84 insertions(+), 36 deletions(-) diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index efea6d336..d608fca33 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -31,6 +31,7 @@ type ManagedAgentSessionPanelProps = { compact?: boolean; emptyDescription?: string; isWorking?: boolean; + rawLayout?: "responsive" | "exclusive"; showHeader?: boolean; showInterventionHint?: boolean; showRaw?: boolean; @@ -44,6 +45,7 @@ export function ManagedAgentSessionPanel({ compact = false, emptyDescription = "Mention this agent in a channel to watch the next turn.", isWorking = false, + rawLayout = "responsive", showHeader = true, showInterventionHint = false, showRaw = true, @@ -108,6 +110,7 @@ export function ManagedAgentSessionPanel({ hasObserver={hasObserver} isWorking={isWorking} profiles={profiles} + rawLayout={rawLayout} showInterventionHint={showInterventionHint} showRaw={showRaw} transcript={scopedTranscript} @@ -161,6 +164,7 @@ function SessionBody({ hasObserver, isWorking, profiles, + rawLayout, showInterventionHint, showRaw, transcript, @@ -174,10 +178,26 @@ function SessionBody({ hasObserver: boolean; isWorking: boolean; profiles?: UserProfileLookup; + rawLayout: "responsive" | "exclusive"; showInterventionHint: boolean; showRaw: boolean; transcript: TranscriptItem[]; }) { + if (showRaw && rawLayout === "exclusive") { + return ( + <> + + + {errorMessage ? ( +

+ + {errorMessage} +

+ ) : null} + + ); + } + return ( <> {!hasObserver ? ( @@ -187,7 +207,7 @@ function SessionBody({ ) : (
- {showRaw ? : null} + {showRaw && rawLayout === "responsive" ? ( + + ) : null}
)} diff --git a/desktop/src/features/agents/ui/RawEventRail.tsx b/desktop/src/features/agents/ui/RawEventRail.tsx index 74a655b77..89d2ac3a2 100644 --- a/desktop/src/features/agents/ui/RawEventRail.tsx +++ b/desktop/src/features/agents/ui/RawEventRail.tsx @@ -1,46 +1,28 @@ -import * as React from "react"; - -import { Button } from "@/shared/ui/button"; import type { ObserverEvent } from "./agentSessionTypes"; import { describeRawEvent } from "./agentSessionTranscript"; export function RawEventRail({ events }: { events: ObserverEvent[] }) { - const [expanded, setExpanded] = React.useState(false); - const visible = expanded ? events : events.slice(-18); - return ( - + ); } diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index e366fe1fe..96293ad62 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -1,4 +1,5 @@ -import { ArrowLeft, CircleDot, Octagon, X } from "lucide-react"; +import * as React from "react"; +import { ArrowLeft, CircleDot, Octagon, TerminalSquare, X } from "lucide-react"; import { toast } from "sonner"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; @@ -25,6 +26,7 @@ import { PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS, } from "@/shared/ui/OverlayPanelBackdrop"; import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth"; +import { Switch } from "@/shared/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; @@ -60,6 +62,19 @@ export function AgentSessionThreadPanel({ useEscapeKey(onClose, isOverlay || isSinglePanelView); const { ref: scrollRef, onScroll } = useStickToBottom(); + const rawFeedScopeKey = `${agent.pubkey}:${channel.id}`; + const [rawFeedState, setRawFeedState] = React.useState(() => ({ + scopeKey: rawFeedScopeKey, + show: false, + })); + const showRawFeed = + rawFeedState.scopeKey === rawFeedScopeKey && rawFeedState.show; + const handleRawFeedChange = React.useCallback( + (checked: boolean) => { + setRawFeedState({ scopeKey: rawFeedScopeKey, show: checked }); + }, + [rawFeedScopeKey], + ); async function handleInterruptTurn() { try { @@ -87,6 +102,32 @@ export function AgentSessionThreadPanel({ Live ) : null} + {isLive ? ( +
+ + +
+ ) : null} {isLive && isWorking ? ( @@ -140,7 +181,9 @@ export function AgentSessionThreadPanel({ > - Activity + + {showRawFeed ? "Raw ACP Activity" : "Activity"} + {agentHeaderActions} @@ -164,9 +207,10 @@ export function AgentSessionThreadPanel({ emptyDescription={`Mention ${agent.name} in the channel to see its work here.`} isWorking={isWorking} profiles={profiles} + rawLayout="exclusive" showHeader={false} showInterventionHint={canInterruptTurn} - showRaw={false} + showRaw={showRawFeed} />
); From 9bcbc05825c3adf096e17bf9e7d9abb92b464171 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 23:28:44 -0700 Subject: [PATCH 04/20] feat(agents): group ACP turn prompts into a user-first transcript bundle - Add turnId/sessionId to transcript items and a presentation-only grouping helper - Render each turn as user prompt first, with setup status and context integrated inline - Show turn setup as a CheckCheck tooltip; expose prompt context via a footer toggle - Expand context sections inside the message bubble; drop the outer grouping border - Add unit tests for turn metadata attachment and grouped display ordering --- .../agents/ui/AgentSessionTranscriptList.tsx | 403 +++++++++++++++++- .../agents/ui/agentSessionTranscript.test.mjs | 95 +++++ .../agents/ui/agentSessionTranscript.ts | 83 ++-- .../agentSessionTranscriptGrouping.test.mjs | 198 +++++++++ .../ui/agentSessionTranscriptGrouping.ts | 208 +++++++++ .../features/agents/ui/agentSessionTypes.ts | 32 +- 6 files changed, 957 insertions(+), 62 deletions(-) create mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts 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; From 4528a11d4831dd0acd67a188c97260f6e263a995 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 23:40:53 -0700 Subject: [PATCH 05/20] feat(agents): show agent avatars in activity transcripts - Add managed agent avatar URLs to the Tauri summary and frontend ManagedAgent mapping so configured persona photos are available in channel activity views. - Carry agent pubkey and avatar data through channel session candidates, ManagedAgentSessionPanel, and AgentSessionTranscriptList. - Replace the assistant transcript bot glyph with UserAvatar, using synced profile photos first and managed-agent avatar URLs as fallback. - Include known agent pubkeys in channel profile lookups and preserve managed-agent avatars when merging agent names into profile data. - Update the E2E mock bridge managed-agent shape so seeded and created mock agents retain avatar URLs. - Polish the assistant activity row with slightly wider spacing and text-xs/font-semibold labels for the agent name and metadata. --- .../src-tauri/src/managed_agents/runtime.rs | 1 + desktop/src-tauri/src/managed_agents/types.rs | 1 + .../agents/ui/AgentSessionTranscriptList.tsx | 107 ++++++++++++------ .../agents/ui/ManagedAgentSessionPanel.tsx | 12 +- .../channels/lib/agentSessionCandidates.ts | 2 + .../features/channels/ui/ChannelScreen.tsx | 40 +++---- .../channels/ui/useChannelActivityTyping.ts | 12 +- desktop/src/shared/api/tauri.ts | 7 +- desktop/src/shared/api/types.ts | 1 + desktop/src/testing/e2eBridge.ts | 5 + 10 files changed, 129 insertions(+), 59 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index d412e790e..8cc93e005 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1356,6 +1356,7 @@ pub fn build_managed_agent_summary( max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, system_prompt: effective_prompt, + avatar_url: record.avatar_url.clone(), model: effective_model, mcp_toolsets: record.mcp_toolsets.clone(), env_vars: record.env_vars.clone(), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e9d733e54..4bd10fe48 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -224,6 +224,7 @@ pub struct ManagedAgentSummary { pub max_turn_duration_seconds: Option, pub parallelism: u32, pub system_prompt: Option, + pub avatar_url: Option, pub model: Option, pub mcp_toolsets: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 4da048712..3017bb0d9 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -17,6 +17,7 @@ import { type UserProfileLookup, } from "@/features/profile/lib/identity"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { Badge } from "@/shared/ui/badge"; import { Markdown } from "@/shared/ui/markdown"; import { Shimmer } from "@/shared/ui/Shimmer"; @@ -39,16 +40,23 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; /** Dev-only: surface the observer wire label that produced each transcript row. */ const SHOW_TRANSCRIPT_ACP_SOURCE = import.meta.env.DEV; +type AgentTranscriptIdentityProps = { + agentAvatarUrl: string | null; + agentName: string; + agentPubkey: string; +}; + export function AgentSessionTranscriptList({ + agentAvatarUrl, agentName, + agentPubkey, compact = false, emptyDescription, isWorking = false, items, profiles, showInterventionHint = false, -}: { - agentName: string; +}: AgentTranscriptIdentityProps & { compact?: boolean; emptyDescription: string; isWorking?: boolean; @@ -98,7 +106,9 @@ export function AgentSessionTranscriptList({ {displayBlocks.map((block) => ( @@ -182,15 +192,15 @@ function TranscriptNowSummary({
-

+

Now

- · -

{agentName}

+ · +

{agentName}

{lastUpdated ? ( <> - · -

+ · +

{lastUpdated}

@@ -210,7 +220,7 @@ function TranscriptNowSummary({

{showInterventionHint && isWorking ? ( -

+

Use Stop{" "} above to interrupt this turn without stopping the agent process.

@@ -254,7 +264,7 @@ function ActivityCountBadge({ }) { return ( {count} {label} @@ -327,13 +337,14 @@ function getDisplayBlockKey(block: TranscriptDisplayBlock) { function TranscriptDisplayBlockView({ activeItemIds, + agentAvatarUrl, agentName, + agentPubkey, block, compact, profiles, -}: { +}: AgentTranscriptIdentityProps & { activeItemIds: ReadonlySet; - agentName: string; block: TranscriptDisplayBlock; compact: boolean; profiles?: UserProfileLookup; @@ -342,7 +353,9 @@ function TranscriptDisplayBlockView({ return ( ( ; - agentName: string; compact: boolean; profiles?: UserProfileLookup; segment: TranscriptTurnSegment; @@ -412,7 +428,9 @@ function TranscriptTurnSegmentView({ return ( @@ -534,7 +552,7 @@ function PromptContextSections({ sections }: { sections: PromptSection[] }) { {section.title} -
+          
             {section.body.trim() || "No metadata."}
           
@@ -590,7 +608,7 @@ function TurnSetupFooter({ {showContext ? ( ; - agentName: string; compact: boolean; item: TranscriptItem; profiles?: UserProfileLookup; @@ -634,7 +653,9 @@ function TranscriptItemRow({ ) : null} ; @@ -734,6 +759,14 @@ function MessageItem({ profiles, }) : item.title || "User"; + const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; + const assistantLabel = resolveUserLabel({ + pubkey: agentPubkey, + fallbackName: agentName, + profiles, + preferResolvedSelfLabel: true, + }); + const assistantAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; return (
@@ -764,14 +797,20 @@ function MessageItem({ )} > {isAssistant ? ( -
- - +
+ + + {assistantLabel} - {agentName} {isActive ? ( @@ -824,7 +863,7 @@ function ThoughtItem({ {item.title} {isActive ? ( @@ -868,7 +907,7 @@ function MetadataItem({ {item.title} - + {item.sections.length} section{item.sections.length === 1 ? "" : "s"} @@ -884,7 +923,7 @@ function MetadataItem({ {section.title} -
+            
               {section.body.trim() || "No metadata."}
             
@@ -942,7 +981,7 @@ function TranscriptTimestamp({ timestamp }: { timestamp: string }) { return ( - + {formatted} diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index d608fca33..001b5ca3f 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -25,7 +25,9 @@ import { shorten } from "./agentSessionUtils"; import { useObserverEvents, useAgentTranscript } from "./useObserverEvents"; type ManagedAgentSessionPanelProps = { - agent: Pick; + agent: Pick & { + avatarUrl?: string | null; + }; channelId?: string | null; className?: string; compact?: boolean; @@ -101,7 +103,9 @@ export function ManagedAgentSessionPanel({ ) : null} & { agentSource: "managed" | "member-bot" | "relay"; + avatarUrl?: string | null; canInterruptTurn: boolean; channelIds?: string[]; channels?: string[]; @@ -57,6 +58,7 @@ export function buildChannelAgentSessionCandidates({ name: agent.name, status: agent.status, agentSource: "managed", + avatarUrl: agent.avatarUrl, canInterruptTurn: true, channelIds: existing?.channelIds, channels: existing?.channels, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 7f8582b0b..a99e3e2d0 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -185,25 +185,6 @@ export function ChannelScreen({ : [], [activeChannel], ); - const messageProfilePubkeys = React.useMemo( - () => [ - ...new Set([ - ...messageAuthorPubkeys, - ...messageMentionPubkeys, - ...activeDmParticipantPubkeys, - ...typingEntries.map((entry) => entry.pubkey), - ]), - ], - [ - activeDmParticipantPubkeys, - messageAuthorPubkeys, - messageMentionPubkeys, - typingEntries, - ], - ); - const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { - enabled: messageProfilePubkeys.length > 0, - }); const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); const channelMembers = channelMembersQuery.data; const managedAgentsQuery = useManagedAgentsQuery(); @@ -225,6 +206,27 @@ export function ChannelScreen({ } return pubkeys; }, [channelMembers, managedAgents, relayAgents]); + const messageProfilePubkeys = React.useMemo( + () => [ + ...new Set([ + ...messageAuthorPubkeys, + ...messageMentionPubkeys, + ...activeDmParticipantPubkeys, + ...agentPubkeys, + ...typingEntries.map((entry) => entry.pubkey), + ]), + ], + [ + activeDmParticipantPubkeys, + agentPubkeys, + messageAuthorPubkeys, + messageMentionPubkeys, + typingEntries, + ], + ); + const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { + enabled: messageProfilePubkeys.length > 0, + }); const allAgentSessionCandidates = React.useMemo( () => buildChannelAgentSessionCandidates({ diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.ts b/desktop/src/features/channels/ui/useChannelActivityTyping.ts index 007f090d3..d3943dbb1 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.ts +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.ts @@ -98,7 +98,7 @@ export function mergeAgentNamesIntoProfiles( relayAgents: RelayAgent[], ): UserProfileLookup { const merged = { ...profiles }; - for (const agent of [...relayAgents, ...managedAgents]) { + for (const agent of relayAgents) { const key = normalizePubkey(agent.pubkey); merged[key] = { ...merged[key], @@ -108,5 +108,15 @@ export function mergeAgentNamesIntoProfiles( isAgent: true, }; } + for (const agent of managedAgents) { + const key = normalizePubkey(agent.pubkey); + merged[key] = { + ...merged[key], + displayName: merged[key]?.displayName || agent.name, + avatarUrl: merged[key]?.avatarUrl ?? agent.avatarUrl, + nip05Handle: merged[key]?.nip05Handle ?? null, + isAgent: true, + }; + } return merged; } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 11e25da4d..0954193e8 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -45,10 +45,7 @@ import type { OpenDmInput, } from "@/shared/api/types"; -type RawIdentity = { - pubkey: string; - display_name: string; -}; +type RawIdentity = { pubkey: string; display_name: string }; type RawProfile = { pubkey: string; @@ -213,6 +210,7 @@ export type RawManagedAgent = { max_turn_duration_seconds: number | null; parallelism: number; system_prompt: string | null; + avatar_url?: string | null; model: string | null; mcp_toolsets: string | null; env_vars?: Record; @@ -868,6 +866,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { maxTurnDurationSeconds: agent.max_turn_duration_seconds, parallelism: agent.parallelism, systemPrompt: agent.system_prompt, + avatarUrl: agent.avatar_url ?? null, model: agent.model, mcpToolsets: agent.mcp_toolsets, envVars: agent.env_vars ?? {}, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 653a1be99..99df2caa4 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -282,6 +282,7 @@ export type ManagedAgent = { maxTurnDurationSeconds: number | null; parallelism: number; systemPrompt: string | null; + avatarUrl: string | null; model: string | null; mcpToolsets: string | null; /** Per-agent env vars. Layered on top of persona envVars. */ diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f3481d234..8b8e59103 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -38,6 +38,7 @@ type MockCommandAvailability = { type MockManagedAgentSeed = { pubkey: string; name: string; + avatarUrl?: string | null; personaId?: string | null; status?: RawManagedAgent["status"]; channelNames?: string[]; @@ -358,6 +359,7 @@ type RawManagedAgent = { max_turn_duration_seconds: number | null; parallelism: number; system_prompt: string | null; + avatar_url: string | null; model: string | null; env_vars?: Record; status: "running" | "stopped" | "deployed" | "not_deployed"; @@ -873,6 +875,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { max_turn_duration_seconds: agent.max_turn_duration_seconds ?? null, parallelism: agent.parallelism, system_prompt: agent.system_prompt, + avatar_url: agent.avatar_url ?? null, model: agent.model, env_vars: { ...(agent.env_vars ?? {}) }, status: agent.status, @@ -962,6 +965,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { max_turn_duration_seconds: null, parallelism: 1, system_prompt: null, + avatar_url: seed.avatarUrl ?? null, model: null, env_vars: {}, status, @@ -4747,6 +4751,7 @@ async function handleCreateManagedAgent( max_turn_duration_seconds: args.input.maxTurnDurationSeconds ?? null, parallelism: args.input.parallelism ?? 1, system_prompt: args.input.systemPrompt?.trim() || null, + avatar_url: avatarUrl, model: args.input.model?.trim() || null, env_vars: { ...(args.input.envVars ?? {}) }, status: args.input.spawnAfterCreate ? "running" : "stopped", From 3c9489a6950db44fc52eda82715a1cbbed8a6cc3 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 14 Jun 2026 23:46:21 -0700 Subject: [PATCH 06/20] refactor(agents): standardize activity transcript typography to text-xs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace arbitrary text-[7px]–text-[11px] sizes with Tailwind text-xs across the agent activity/session surface - AgentSessionTranscriptList, AgentSessionThreadPanel, AgentSessionToolItem, RawEventRail, and BotActivityBar - UserAvatar xs/sm size tokens now use text-xs so transcript and activity-bar avatars need no per-call font overrides --- .../src/features/agents/ui/AgentSessionToolItem.tsx | 8 ++++---- .../features/agents/ui/AgentSessionTranscriptList.tsx | 2 +- desktop/src/features/agents/ui/RawEventRail.tsx | 2 +- .../features/channels/ui/AgentSessionThreadPanel.tsx | 9 +++------ desktop/src/features/channels/ui/BotActivityBar.tsx | 10 ++++++---- desktop/src/shared/ui/UserAvatar.tsx | 4 ++-- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 959a767d7..3ed633ac6 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -81,7 +81,7 @@ export function ToolItem({ {isActive ? ( @@ -220,7 +220,7 @@ function ToolTimestamp({ return ( - + {time} {duration ? ` · ${duration}` : null} @@ -282,7 +282,7 @@ function BuzzToolInlineAction({ if (action.onClick) { return ( - ); - } - - return ( - - {action.avatar} - {action.label} - {action.value} - - ); -} - -type BuzzToolInlineActionModel = { - avatar?: React.ReactNode; - label: string; - value: string; - title: string; - onClick?: () => void; -}; - -function getBuzzToolInlineAction({ - args, - channelId, - channels, - openChannel, - profiles, - resultValue, -}: { - args: Record; - channelId: string | null; - channels: Channel[]; - openChannel: (messageId?: string) => void; - profiles: Record | undefined; - resultValue: unknown; -}): BuzzToolInlineActionModel | null { - const resultRecord = asRecord(resultValue); - const eventId = - getToolString(args, ["event_id", "eventId"]) ?? - getToolString(resultRecord, ["event_id", "eventId", "id"]); - - if (eventId && channelId) { - return { - label: resultRecord.accepted === true ? "posted" : "event", - onClick: () => openChannel(eventId), - title: eventId, - value: getChannelChipLabel(channels, channelId), - }; - } - - const messages = getResultArray(resultValue, resultRecord, "messages"); - if (messages) { - return { - label: "read", - onClick: channelId ? () => openChannel() : undefined, - title: `${messages.length} messages`, - value: `${messages.length} message${messages.length === 1 ? "" : "s"}`, - }; - } - - if (channelId) { - return { - label: "channel", - onClick: () => openChannel(), - title: channelId, - value: getChannelChipLabel(channels, channelId), - }; - } - - const workflowId = - getToolString(args, ["workflow_id", "workflowId"]) ?? - getToolString(resultRecord, ["workflow_id", "workflowId"]); - if (workflowId) { - return { - label: "workflow", - title: workflowId, - value: shortenMiddle(workflowId, 26), - }; - } - - const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]); - if (pubkeys.length > 0) { - if (pubkeys.length === 1) { - const pk = pubkeys[0]; - const displayName = resolveUserLabel({ pubkey: pk, profiles }); - const profile = profiles?.[pk.toLowerCase()]; - return { - avatar: ( - - ), - label: "user", - title: pk, - value: displayName, - }; - } - return { - label: "users", - title: pubkeys - .map((pk) => resolveUserLabel({ pubkey: pk, profiles })) - .join(", "), - value: `${pubkeys.length} users`, - }; - } - - const query = getToolString(args, ["query"]); - if (query) { - return { - label: "query", - title: query, - value: shortenMiddle(query, 30), - }; - } - - if (typeof resultRecord.accepted === "boolean") { - return { - label: "relay", - title: resultRecord.accepted ? "accepted" : "rejected", - value: resultRecord.accepted ? "accepted" : "rejected", - }; - } - - return null; -} - function parseToolResultValue(result: string): unknown { const trimmed = result.trim(); if (!trimmed) return null; @@ -682,8 +378,3 @@ function parseToolResultValue(result: string): unknown { return null; } } - -function getChannelChipLabel(channels: Channel[], channelId: string) { - const channel = channels.find((candidate) => candidate.id === channelId); - return channel ? `#${channel.name}` : `#${shortenMiddle(channelId, 22)}`; -} diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index e45ff17dd..8a507d09d 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -15,7 +15,6 @@ import { } from "@/features/profile/lib/identity"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { Badge } from "@/shared/ui/badge"; import { Markdown } from "@/shared/ui/markdown"; import { Toggle } from "@/shared/ui/toggle"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -29,12 +28,35 @@ import { type TranscriptDisplayBlock, type TranscriptTurnSegment, } from "./agentSessionTranscriptGrouping"; -import { buildTranscriptPresentation } from "./agentSessionTranscriptPresentation"; import { formatTranscriptTime } from "./agentSessionUtils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -/** Dev-only: surface the observer wire label that produced each transcript row. */ -const SHOW_TRANSCRIPT_ACP_SOURCE = import.meta.env.DEV; +const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source"; + +/** + * Opt-in only: source pills are useful while iterating on observer parsing, but + * they should not appear for every local dev session. + */ +const SHOW_TRANSCRIPT_ACP_SOURCE = shouldShowTranscriptAcpSource(); + +function shouldShowTranscriptAcpSource() { + const envValue = import.meta.env.VITE_SHOW_TRANSCRIPT_ACP_SOURCE; + if (envValue === "1" || envValue === "true") { + return true; + } + + if (typeof window === "undefined") { + return false; + } + + try { + return ( + window.localStorage.getItem(TRANSCRIPT_ACP_SOURCE_STORAGE_KEY) === "1" + ); + } catch { + return false; + } +} type AgentTranscriptIdentityProps = { agentAvatarUrl: string | null; @@ -48,7 +70,6 @@ export function AgentSessionTranscriptList({ agentPubkey, compact = false, emptyDescription, - isWorking = false, items, profiles, }: AgentTranscriptIdentityProps & { @@ -58,10 +79,6 @@ export function AgentSessionTranscriptList({ items: TranscriptItem[]; profiles?: UserProfileLookup; }) { - const presentation = React.useMemo( - () => buildTranscriptPresentation(items, isWorking), - [items, isWorking], - ); const displayBlocks = React.useMemo( () => buildTranscriptDisplayBlocks(items), [items], @@ -92,7 +109,6 @@ export function AgentSessionTranscriptList({ > {displayBlocks.map((block) => ( ; block: TranscriptDisplayBlock; compact: boolean; profiles?: UserProfileLookup; @@ -143,7 +157,6 @@ function TranscriptDisplayBlockView({ if (block.kind === "single") { return ( {block.segments.map((segment) => ( ; compact: boolean; profiles?: UserProfileLookup; segment: TranscriptTurnSegment; @@ -218,7 +228,6 @@ function TranscriptTurnSegmentView({ return (

{text}

{contextOpen && context ? ( - + ) : null}
[]; +}) { return (
+ {sections.map((section) => (
[]; +}) { + const label = formatTurnSetupLabel(items); + const detail = turnSetupDetail(items); + const setupText = [label, detail].filter(Boolean).join(" · "); + + if (!setupText) { + return null; + } + + return ( +

+ {setupText} +

+ ); +} + function TurnSetupFooter({ context = null, contextOpen = false, @@ -375,12 +414,35 @@ function TurnSetupFooter({ return ; } + const contextToggle = showContext ? ( + + {showSetup ? + ) : null; + return (
- {showSetup ? ( + {showContext && showSetup ? ( + + {contextToggle} + +

{tooltipText}

+
+
+ ) : null} + {!showContext && showSetup ? (
); } function TranscriptItemRow({ - activeItemIds, agentAvatarUrl, agentName, agentPubkey, @@ -422,7 +472,6 @@ function TranscriptItemRow({ item, profiles, }: AgentTranscriptIdentityProps & { - activeItemIds: ReadonlySet; compact: boolean; item: TranscriptItem; profiles?: UserProfileLookup; @@ -444,7 +493,6 @@ function TranscriptItemRow({ agentName={agentName} agentPubkey={agentPubkey} compact={compact} - isActive={activeItemIds.has(item.id)} item={item} profiles={profiles} /> @@ -486,12 +534,10 @@ const TranscriptItemView = React.memo(function TranscriptItemView({ agentName, agentPubkey, compact, - isActive, item, profiles, }: AgentTranscriptIdentityProps & { compact: boolean; - isActive: boolean; item: TranscriptItem; profiles?: UserProfileLookup; }) { @@ -502,17 +548,16 @@ const TranscriptItemView = React.memo(function TranscriptItemView({ agentName={agentName} agentPubkey={agentPubkey} compact={compact} - isActive={isActive} item={item} profiles={profiles} /> ); } if (item.type === "tool") { - return ; + return ; } if (item.type === "thought") { - return ; + return ; } if (item.type === "metadata") { return ; @@ -525,12 +570,10 @@ function MessageItem({ agentName, agentPubkey, compact, - isActive, item, profiles, }: AgentTranscriptIdentityProps & { compact: boolean; - isActive: boolean; item: Extract; profiles?: UserProfileLookup; }) { @@ -561,9 +604,6 @@ function MessageItem({ "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 && - isActive && - "rounded-lg border border-primary/15 bg-primary/3 px-2 py-1.5", )} data-role={isAssistant ? "assistant-message" : "user-message"} data-testid={ @@ -596,15 +636,6 @@ function MessageItem({ {assistantLabel} - {isActive ? ( - - - Live - - ) : null}
) : null} @@ -630,11 +661,9 @@ function MessageItem({ function ThoughtItem({ compact, - isActive, item, }: { compact: boolean; - isActive: boolean; item: Extract; }) { return ( @@ -642,22 +671,12 @@ function ThoughtItem({ className={cn( "group not-prose w-full rounded-md border border-transparent", compact ? "px-0" : "px-1", - isActive && "border-primary/15 bg-primary/3 px-2 py-1", )} data-testid="transcript-thought-item" > - + {item.title} - {isActive ? ( - - - Live - - ) : null} From df72cc9f93b34155b60e4f872c1f7548ea08a513 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 15 Jun 2026 01:03:23 -0700 Subject: [PATCH 15/20] fix(agents): group activity tools and hide orphan prompt context - Update desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts to group consecutive tool calls into transcript tool groups and suppress setup/context-only turn noise when no user prompt bubble exists - Update desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx and AgentSessionToolItem.tsx to render grouped tool rows with tighter activity-feed spacing - Expand desktop/src/features/agents/ui/agentSessionToolSummary.ts so Buzz and generic tools use the compact summary presentation with useful previews - Adjust desktop/src/shared/ui/toggle.tsx to support the ghost prompt-context toggle styling used by the transcript footer - Add focused unit coverage for grouped tools, Buzz tool summaries, promptless turns, and setup/context-only turns --- .../agents/ui/AgentSessionToolItem.tsx | 10 +- .../agents/ui/AgentSessionTranscriptList.tsx | 105 +++++++++++--- .../ui/agentSessionToolSummary.test.mjs | 37 ++--- .../agents/ui/agentSessionToolSummary.ts | 128 +++++++++++++++--- .../agentSessionTranscriptGrouping.test.mjs | 34 ++++- .../ui/agentSessionTranscriptGrouping.ts | 38 +++++- desktop/src/shared/ui/toggle.tsx | 4 +- 7 files changed, 281 insertions(+), 75 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 9fc4b1111..683896cfd 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 8a507d09d..3895b837f 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 d24100ffc..7b416eb59 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 70aaccbbf..96b024469 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,11 +226,54 @@ 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 { diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 83f1fb534..07ba3b2e5 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 74fa75e5c..3a840e22f 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 939b2df16..e6e9eac4f 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", }, From 91b2e4a0128427c587f792fac819ac6640818582 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 15 Jun 2026 01:14:10 -0700 Subject: [PATCH 16/20] refactor(agents): drop transcript compact mode and simplify row spacing - Remove the compact layout prop from AgentSessionTranscriptList, ManagedAgentSessionPanel, and AgentSessionThreadPanel; always render the tighter transcript density - Revert consecutive tool-call grouping (tool_group segments, ToolCallGroup, segment spacing helpers) and render each tool as a normal transcript row - Add symmetric per-type row spacing: my-2.5 for messages, my-1 for tools, my-2 for other items - Simplify ToolItem wrapper padding now that grouped/compact props are gone --- .../agents/ui/AgentSessionToolItem.tsx | 10 +- .../agents/ui/AgentSessionTranscriptList.tsx | 180 +++--------------- .../agents/ui/ManagedAgentSessionPanel.tsx | 6 - .../agentSessionTranscriptGrouping.test.mjs | 25 --- .../ui/agentSessionTranscriptGrouping.ts | 36 +--- .../channels/ui/AgentSessionThreadPanel.tsx | 1 - 6 files changed, 31 insertions(+), 227 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 683896cfd..c06c8add8 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -10,12 +10,8 @@ import { buildCompactToolSummary } from "./agentSessionToolSummary"; 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); @@ -34,11 +30,7 @@ export function ToolItem({ return (
+

No ACP activity yet

{emptyDescription}

@@ -104,7 +97,7 @@ export function AgentSessionTranscriptList({
{displayBlocks.map((block) => ( @@ -113,7 +106,6 @@ export function AgentSessionTranscriptList({ agentName={agentName} agentPubkey={agentPubkey} block={block} - compact={compact} key={getDisplayBlockKey(block)} profiles={profiles} /> @@ -147,28 +139,17 @@ function TranscriptDisplayBlockView({ agentName, agentPubkey, block, - compact, profiles, }: AgentTranscriptIdentityProps & { block: TranscriptDisplayBlock; - compact: boolean; profiles?: UserProfileLookup; }) { if (block.kind === "single") { - if (block.item.type === "tool") { - return ( -
- -
- ); - } - return ( @@ -177,51 +158,24 @@ function TranscriptDisplayBlockView({ return (
- {block.segments.map((segment, index) => ( -
( + - -
+ profiles={profiles} + segment={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`; @@ -229,9 +183,6 @@ 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; } @@ -239,18 +190,15 @@ function TranscriptTurnSegmentView({ agentAvatarUrl, agentName, agentPubkey, - compact, profiles, segment, }: AgentTranscriptIdentityProps & { - compact: boolean; profiles?: UserProfileLookup; segment: TranscriptTurnSegment; }) { if (segment.kind === "prompt") { return ( ; - } - - if (segment.kind === "tool_group") { - return ; + return ; } return ( @@ -272,8 +216,6 @@ function TranscriptTurnSegmentView({ agentAvatarUrl={agentAvatarUrl} agentName={agentName} agentPubkey={agentPubkey} - compact={compact} - embedded item={segment.item} profiles={profiles} /> @@ -281,13 +223,11 @@ function TranscriptTurnSegmentView({ } function TurnPromptBlock({ - compact, context, profiles, setup, user, }: { - compact: boolean; context: Extract | null; profiles?: UserProfileLookup; setup: Extract[]; @@ -304,7 +244,6 @@ function TurnPromptBlock({
) : null} | null; item: Extract; profiles?: UserProfileLookup; @@ -353,12 +290,7 @@ function PromptUserMessage({ size="xs" />
-
+

{text}

{contextOpen && context ? ( @@ -503,43 +435,19 @@ 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 (
{SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? ( @@ -549,7 +457,6 @@ function TranscriptItemRow({ agentAvatarUrl={agentAvatarUrl} agentName={agentName} agentPubkey={agentPubkey} - compact={compact} item={item} profiles={profiles} /> @@ -558,10 +465,8 @@ function TranscriptItemRow({ } function TurnSetupStatus({ - compact, items, }: { - compact: boolean; items: Extract[]; }) { const timestamp = turnSetupTimestamp(items); @@ -570,31 +475,29 @@ function TurnSetupStatus({ } return ( -
+
); } -function getItemSpacingClass(item: TranscriptItem) { - if (item.type === "lifecycle") { - return "mt-1.5 first:mt-0"; +function getTranscriptItemRowSpacing(item: TranscriptItem): string { + if (item.type === "message") { + return "my-2.5"; } - if (item.type === "metadata" || item.type === "thought") { - return "mt-1.5 first:mt-0"; + if (item.type === "tool") { + return "my-1"; } - return undefined; + return "my-2"; } const TranscriptItemView = React.memo(function TranscriptItemView({ agentAvatarUrl, agentName, agentPubkey, - compact, item, profiles, }: AgentTranscriptIdentityProps & { - compact: boolean; item: TranscriptItem; profiles?: UserProfileLookup; }) { @@ -604,20 +507,19 @@ const TranscriptItemView = React.memo(function TranscriptItemView({ agentAvatarUrl={agentAvatarUrl} agentName={agentName} agentPubkey={agentPubkey} - compact={compact} item={item} profiles={profiles} /> ); } if (item.type === "tool") { - return ; + return ; } if (item.type === "thought") { - return ; + return ; } if (item.type === "metadata") { - return ; + return ; } return ; }); @@ -626,11 +528,9 @@ function MessageItem({ agentAvatarUrl, agentName, agentPubkey, - compact, item, profiles, }: AgentTranscriptIdentityProps & { - compact: boolean; item: Extract; profiles?: UserProfileLookup; }) { @@ -659,14 +559,9 @@ function MessageItem({
; }) { return (
@@ -751,27 +641,13 @@ function ThoughtItem({ } function MetadataItem({ - compact, - embedded = false, item, }: { - compact: boolean; - embedded?: boolean; item: Extract; }) { return (
diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index feef26d3c..aa13ab17e 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -30,7 +30,6 @@ type ManagedAgentSessionPanelProps = { }; channelId?: string | null; className?: string; - compact?: boolean; emptyDescription?: string; isWorking?: boolean; rawLayout?: "responsive" | "exclusive"; @@ -43,7 +42,6 @@ export function ManagedAgentSessionPanel({ agent, channelId = null, className, - compact = false, emptyDescription = "Mention this agent in a channel to watch the next turn.", isWorking = false, rawLayout = "responsive", @@ -104,7 +102,6 @@ export function ManagedAgentSessionPanel({ agentAvatarUrl={agent.avatarUrl ?? null} agentName={agent.name} agentPubkey={agent.pubkey} - compact={compact} connectionState={connectionState} emptyDescription={emptyDescription} errorMessage={errorMessage} @@ -159,7 +156,6 @@ function SessionBody({ agentAvatarUrl, agentName, agentPubkey, - compact, connectionState, emptyDescription, errorMessage, @@ -174,7 +170,6 @@ function SessionBody({ agentAvatarUrl: string | null; agentName: string; agentPubkey: string; - compact: boolean; connectionState: ConnectionState; emptyDescription: string; errorMessage: string | null; @@ -219,7 +214,6 @@ function SessionBody({ agentAvatarUrl={agentAvatarUrl} agentName={agentName} agentPubkey={agentPubkey} - compact={compact} emptyDescription={emptyDescription} isWorking={isWorking} items={transcript} diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs index 07ba3b2e5..7639a97c6 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -124,32 +124,7 @@ 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", () => { diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index 3a840e22f..d350ca299 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -2,7 +2,6 @@ import type { TranscriptItem } from "./agentSessionTypes"; export type TranscriptTurnSegment = | { kind: "item"; item: TranscriptItem } - | { kind: "tool_group"; items: Extract[] } | { kind: "setup"; items: Extract[] } | { kind: "prompt"; @@ -68,9 +67,7 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { const activity = items.filter((item) => !consumed.has(item)); if (!userPrompt) { - return groupConsecutiveToolSegments( - activity.map((item) => ({ kind: "item", item })), - ); + return activity.map((item) => ({ kind: "item", item })); } const segments: TranscriptTurnSegment[] = [ @@ -93,34 +90,7 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { segments.push({ kind: "item", item }); } - 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; + return segments; } /** @@ -192,8 +162,6 @@ 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/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index cb34a2fe6..fe8c58b0b 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -200,7 +200,6 @@ export function AgentSessionThreadPanel({ agent={agent} channelId={channel.id} className="border-0 bg-transparent p-0 shadow-none" - compact emptyDescription={`Mention ${agent.name} in the channel to see its work here.`} isWorking={isWorking} profiles={profiles} From 51dfa3a0ad12f125d2ae860ace68ab4a51a9851a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 15 Jun 2026 01:50:14 -0700 Subject: [PATCH 17/20] fix(desktop): make compact tool label map type honest The labels Record was annotated Record but only spreads generic + developerToolLabels(), which excludes the buzz kind. buzz is handled by an early return in compactToolLabel, so it never indexes this map. Narrow the annotation to Exclude so tsc accepts it, and drop the redundant labelsMap re-cast. No runtime behavior change. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/agents/ui/agentSessionToolSummary.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index 96b024469..f82fbd4c2 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -131,7 +131,7 @@ function compactToolLabel( } const labels: Record< - CompactToolKind, + Exclude, { completed: string; running: string; failed: string } > = { generic: { @@ -142,12 +142,7 @@ function compactToolLabel( ...developerToolLabels(), }; - const labelsMap = labels as Record< - CompactToolKind, - { completed: string; running: string; failed: string } - >; - - const set = labelsMap[kind]; + const set = labels[kind]; if (failed) return set.failed; if (running) return set.running; return set.completed; From a7d6df9f3cb019ce7cf5c3b05499ae9cd82b51f6 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 15 Jun 2026 10:31:26 -0700 Subject: [PATCH 18/20] fix(agents): render formatted prompt bubbles - Render user prompt transcript bubbles through the shared Markdown component so bullets, line breaks, and inline formatting display like normal messages - Preserve multi-line Buzz event Content fields when parsing ACP prompt payloads, stopping before harness metadata such as Tags and Parsed fields - Add transcript helper coverage for a multi-line mention prompt to guard against flattening formatted responses back to one line --- .../agents/ui/AgentSessionTranscriptList.tsx | 4 +-- .../ui/agentSessionTranscriptHelpers.test.mjs | 31 +++++++++++++++++ .../ui/agentSessionTranscriptHelpers.ts | 34 +++++++++++++++++-- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index f12e650e4..358e369a7 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -291,7 +291,7 @@ function PromptUserMessage({ />
-

{text}

+ {contextOpen && context ? ( ) : null} @@ -607,7 +607,7 @@ function MessageItem({ ) : ( <> -

{text}

+ )} diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index e84af1468..1f7b4b5a2 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -65,6 +65,37 @@ test("parsePromptText extracts content, hex pubkey, and a title-cased kind", () ); }); +test("parsePromptText preserves multiline event content in the user bubble text", () => { + const text = [ + "[Buzz event: @mention]", + "Event ID: event-1", + "Channel: agents", + `From: tho (hex: ${HEX})`, + "Time: 2026-06-15T17:15:00Z", + "Content: @Ned", + "", + "- remove that stray cherry pick if it's not adding value here", + "- help me understand what that e2eBridge change does", + "- we'd want the e2e seed path as a separate pull request", + 'Tags: [["h","agents"]]', + "Parsed: mentions=[Ned]", + ].join("\n"); + + const result = parsePromptText(text); + + assert.equal( + result.userText, + [ + "@Ned", + "", + "- remove that stray cherry pick if it's not adding value here", + "- help me understand what that e2eBridge change does", + "- we'd want the e2e seed path as a separate pull request", + ].join("\n"), + ); + assert.equal(result.userPubkey, HEX); +}); + test("parsePromptText lowercases the extracted hex pubkey", () => { const text = [ "[Buzz event: dm]", diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 00c13c056..b74f8cd8a 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -85,9 +85,39 @@ function parsePromptSections(text: string): PromptSection[] { return sections; } +const EVENT_CONTENT_BOUNDARY_RE = + /^(?:Event ID|Channel|Kind|From|Time|Tags|Parsed):\s*/; +const EVENT_BLOCK_BOUNDARY_RE = /^--- Event \d+\b/; + function extractEventContent(body: string): string { - const contentMatch = body.match(/^Content:\s*(.*)$/m); - return contentMatch?.[1]?.trim() ?? ""; + const lines = body.split(/\r?\n/); + const chunks: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^Content:\s?(.*)$/); + if (!match) { + continue; + } + + const contentLines = [match[1] ?? ""]; + for (let j = i + 1; j < lines.length; j++) { + const line = lines[j]; + if ( + EVENT_CONTENT_BOUNDARY_RE.test(line) || + EVENT_BLOCK_BOUNDARY_RE.test(line) + ) { + break; + } + contentLines.push(line); + } + + const content = contentLines.join("\n").trim(); + if (content) { + chunks.push(content); + } + } + + return chunks.join("\n\n"); } function extractEventAuthorPubkey(body: string): string | null { From c0aa2564ad71fb3f5bf27a1f96f0d0901a2c872f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 15 Jun 2026 15:45:56 -0700 Subject: [PATCH 19/20] fix(agents): style message tool rows as chat bubbles with inset media - Render agent message-type tool rows (Send Message and buzz CLI message sends) as styled chat bubbles via a new CompactMessageSummary, gated on a presentation: "message" field on CompactToolSummary - Detect buzz message-send commands from both the structured Buzz tool and shell invocations (including simple echo pipes) so transcripts show the actual sent content - Add a mediaInset markdown prop that insets images and videos inside message bubbles so media no longer bleeds to the bubble edge; wire it into PromptUserMessage and MessageItem - Extend agentSessionToolSummary tests to cover the presentation field across message and inline cases Folds in PR-2 UI polish that was orphaned when the working tree was parked during the PR split. --- .../agents/ui/AgentSessionToolItem.tsx | 75 +++++- .../agents/ui/AgentSessionTranscriptList.tsx | 4 +- .../ui/agentSessionToolSummary.test.mjs | 35 +++ .../agents/ui/agentSessionToolSummary.ts | 224 ++++++++++++++++++ desktop/src/shared/ui/markdown.tsx | 24 +- 5 files changed, 347 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index c06c8add8..823852142 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Send } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; @@ -40,16 +40,27 @@ export function ToolItem({ > - + {compactSummary.presentation === "message" ? ( + + ) : ( + + )} +
+

+ {preview || "Message content unavailable."} +

+
+
+ + + {label} + + {duration ? ( + {duration} + ) : null} + +
+
+ ); +} + function resolveImageSrc(source: string): string { if (source.startsWith("data:image/")) { return source; @@ -159,7 +216,7 @@ function ViewImageToolPreview({ {/* biome-ignore lint/a11y/useKeyWithClickEvents: opens lightbox on click */} {alt} setLightboxOpen(true)} diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 358e369a7..a7d3b99a9 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -291,7 +291,7 @@ function PromptUserMessage({ />
- + {contextOpen && context ? ( ) : null} @@ -607,7 +607,7 @@ function MessageItem({ ) : ( <> - + )} diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs index 7b416eb59..fe25dc2ca 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -36,6 +36,40 @@ test("buildCompactToolSummary formats Buzz send_message preview", () => { assert.equal(summary.kind, "buzz"); assert.equal(summary.label, "Send Message"); assert.equal(summary.preview, "Hello team"); + assert.equal(summary.presentation, "message"); +}); + +test("buildCompactToolSummary treats buzz messages send commands as messages", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "buzz-dev-mcp__shell", + args: { + command: + 'buzz --format compact messages send --channel channel-1 --content "@Ned are you working"', + }, + }), + ); + + assert.equal(summary.kind, "shell"); + assert.equal(summary.label, "Send Message"); + assert.equal(summary.preview, "@Ned are you working"); + assert.equal(summary.presentation, "message"); +}); + +test("buildCompactToolSummary extracts simple piped buzz message content", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "shell", + args: { + command: + 'echo "hello from stdin" | ./target/release/buzz messages send --channel channel-1 --content -', + }, + }), + ); + + assert.equal(summary.label, "Send Message"); + assert.equal(summary.preview, "hello from stdin"); + assert.equal(summary.presentation, "message"); }); test("buildCompactToolSummary formats shell command preview", () => { @@ -48,6 +82,7 @@ test("buildCompactToolSummary formats shell command preview", () => { assert.equal(summary.label, "Ran command"); assert.equal(summary.preview, "git status"); + assert.equal(summary.presentation, "inline"); }); test("buildCompactToolSummary formats view_image thumbnail source", () => { diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index f82fbd4c2..02ea90e2f 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -29,6 +29,7 @@ export type CompactToolSummary = { preview: string | null; /** When set, the compact row renders a tiny image instead of text preview. */ thumbnailSrc: string | null; + presentation: "inline" | "message"; }; const DEVELOPER_TOOL_BASES = new Set([ @@ -46,12 +47,24 @@ type ToolItem = Extract; /** Build the muted compact summary label and preview for any tool row. */ export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { const kind = resolveCompactToolKind(item); + const messageSendPreview = extractMessageSendPreview(item, kind); + if (messageSendPreview !== undefined) { + return { + kind, + label: compactMessageSendLabel(item.status, item.isError), + preview: messageSendPreview, + thumbnailSrc: null, + presentation: "message", + }; + } + const { preview, thumbnailSrc } = extractCompactToolPreview(item, kind); return { kind, label: compactToolLabel(kind, item, item.status, item.isError), preview, thumbnailSrc, + presentation: "inline", }; } @@ -196,6 +209,13 @@ function developerToolLabels(): Record< }; } +function compactMessageSendLabel(status: ToolStatus, isError: boolean) { + if (isError || status === "failed") { + return "Send Message failed"; + } + return "Send Message"; +} + type CompactToolPreview = { preview: string | null; thumbnailSrc: string | null; @@ -271,6 +291,210 @@ function extractBuzzToolPreview(args: Record): string | null { return getToolString(args, ["event_id", "eventId", "name"]); } +function extractMessageSendPreview( + item: ToolItem, + kind: CompactToolKind, +): string | null | undefined { + if (isBuzzSendMessageTool(item)) { + return extractBuzzToolMessageContent(item.args); + } + + if (kind !== "shell") { + return undefined; + } + + const command = getToolString(item.args, ["command"]); + return command ? extractBuzzCliSendMessageContent(command) : undefined; +} + +function isBuzzSendMessageTool(item: ToolItem) { + return [item.buzzToolName, item.toolName, item.title].some((value) => { + if (!value) return false; + return normalizeToolNameText(value) === "send_message"; + }); +} + +function extractBuzzToolMessageContent( + args: Record, +): string | null { + return getToolString(args, ["content", "message", "text", "body"]); +} + +function extractBuzzCliSendMessageContent( + command: string, +): string | null | undefined { + const tokens = tokenizeShellCommand(command); + const commandRange = findBuzzMessagesSendCommand(tokens); + if (!commandRange) { + return undefined; + } + + const content = getFlagValue(tokens, commandRange.sendIndex + 1, "--content"); + if (!content) { + return null; + } + if (content !== "-") { + return content; + } + + const pipedContent = extractSimpleEchoPipeContent( + tokens, + commandRange.buzzIndex, + ); + return pipedContent ?? null; +} + +function tokenizeShellCommand(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + let escaping = false; + + const pushCurrent = () => { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + }; + + for (const char of command) { + if (escaping) { + current += char; + escaping = false; + continue; + } + + if (char === "\\" && quote !== "'") { + escaping = true; + continue; + } + + if (quote) { + if (char === quote) { + quote = null; + } else { + current += char; + } + continue; + } + + if (char === "'" || char === '"') { + quote = char; + continue; + } + + if (/\s/.test(char)) { + pushCurrent(); + continue; + } + + if (char === "|" || char === ";" || char === "&") { + pushCurrent(); + tokens.push(char); + continue; + } + + current += char; + } + + if (escaping) { + current += "\\"; + } + pushCurrent(); + return tokens; +} + +function findBuzzMessagesSendCommand( + tokens: string[], +): { buzzIndex: number; sendIndex: number } | null { + for (let i = 0; i < tokens.length; i++) { + if (!isBuzzExecutable(tokens[i])) { + continue; + } + + const messagesIndex = tokens.indexOf("messages", i + 1); + if (messagesIndex === -1) { + continue; + } + if ( + messagesIndex > i && + hasCommandSeparator(tokens, i + 1, messagesIndex) + ) { + continue; + } + if (tokens[messagesIndex + 1] === "send") { + return { buzzIndex: i, sendIndex: messagesIndex + 1 }; + } + } + + return null; +} + +function isBuzzExecutable(token: string) { + return token === "buzz" || token.split(/[\\/]/).pop() === "buzz"; +} + +function hasCommandSeparator(tokens: string[], start: number, end: number) { + for (let i = start; i < end; i++) { + if (isCommandSeparator(tokens[i])) { + return true; + } + } + return false; +} + +function isCommandSeparator(token: string) { + return token === "|" || token === ";" || token === "&"; +} + +function getFlagValue(tokens: string[], start: number, flag: string) { + for (let i = start; i < tokens.length; i++) { + const token = tokens[i]; + if (isCommandSeparator(token)) { + return null; + } + if (token === flag) { + return tokens[i + 1] && !isCommandSeparator(tokens[i + 1]) + ? tokens[i + 1] + : null; + } + if (token.startsWith(`${flag}=`)) { + return token.slice(flag.length + 1); + } + } + return null; +} + +function extractSimpleEchoPipeContent( + tokens: string[], + buzzIndex: number, +): string | null { + const pipeIndex = tokens.lastIndexOf("|", buzzIndex); + if (pipeIndex <= 0) { + return null; + } + + const echoStart = findSegmentStart(tokens, pipeIndex - 1); + const leftSegment = tokens.slice(echoStart, pipeIndex); + if (leftSegment[0] !== "echo") { + return null; + } + + const contentTokens = leftSegment + .slice(1) + .filter((token) => !token.startsWith("-")); + return contentTokens.length > 0 ? contentTokens.join(" ") : null; +} + +function findSegmentStart(tokens: string[], beforeIndex: number) { + for (let i = beforeIndex; i >= 0; i--) { + if (isCommandSeparator(tokens[i])) { + return i + 1; + } + } + return 0; +} + function textPreview(preview: string | null): CompactToolPreview { return { preview, thumbnailSrc: null }; } diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 61e498f9e..3a3eb50fc 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -224,6 +224,7 @@ type MarkdownProps = { agentMentionPubkeysByName?: Record; mentionNames?: string[]; mentionPubkeysByName?: Record; + mediaInset?: boolean; searchQuery?: string; tight?: boolean; videoReviewContext?: VideoReviewContext; @@ -748,6 +749,7 @@ function createMarkdownComponents( variant: MarkdownVariant, runtimeRef: React.RefObject, interactive = true, + mediaInset = false, ): Components { const paragraphClassName = variant === "tight" @@ -898,7 +900,12 @@ function createMarkdownComponents( if (resolvedSrc?.endsWith(".mp4")) { const entry = src ? imetaByUrl?.get(src) : undefined; return ( - + + ); @@ -1115,6 +1128,7 @@ function MarkdownInner({ imetaByUrl, interactive = true, agentMentionPubkeysByName, + mediaInset = false, mentionNames, mentionPubkeysByName, searchQuery, @@ -1161,8 +1175,9 @@ function MarkdownInner({ }); const components = React.useMemo( - () => createMarkdownComponents(variant, runtimeRef, interactive), - [variant, runtimeRef, interactive], + () => + createMarkdownComponents(variant, runtimeRef, interactive, mediaInset), + [variant, runtimeRef, interactive, mediaInset], ); // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable @@ -1276,6 +1291,7 @@ export const Markdown = React.memo( prev.compact === next.compact && prev.customEmoji === next.customEmoji && prev.interactive === next.interactive && + prev.mediaInset === next.mediaInset && prev.tight === next.tight && prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName && prev.mentionPubkeysByName === next.mentionPubkeysByName && From ab29e2c086bbebb02f3bb176fa71d8738caeb4d9 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 15 Jun 2026 20:12:15 -0700 Subject: [PATCH 20/20] test(agents): cover transcript render-decision helpers + sweep dead branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity-feed UI components claimed coverage "for grouping, presentation, and tool summaries" but the render-decision logic wiring those helpers into the rendered components had none. The node test harness only strips .ts (no JSX/DOM), so this extracts the pure decision logic out of the .tsx components into sibling .ts modules and tests it directly — matching the existing helper-test convention. - AgentSessionToolItem.tsx: extract isInlineImageData (the data:image/ passthrough guard), getToolDurationDisplay/formatDurationMs (duration fallback chain), and parseToolResultValue (JSON double-parse) into agentSessionUtils.ts. resolveImageSrc stays in the component (Tauri dep) but is now built on the testable isInlineImageData predicate. - ManagedAgentSessionPanel.tsx: extract scopeByChannel, deriveLatestSessionId, and resolveRawRailLayout (the raw-ACP toggle decision) into agentSessionPanelLayout.ts. - describeRawEvent (raw-view labels) gets direct coverage. New tests: agentSessionToolItemHelpers.test.mjs, rawEventRail.test.mjs, agentSessionPanelLayout.test.mjs (32 cases). Total suite 766 -> 798. Also sweeps the two parked dead branches: - agentSessionTranscriptGrouping.ts: the isSetupLifecycle guard in the activity loop can never fire (setup items are already in `consumed` and filtered out of `activity`). - agentSessionToolSummary.ts: the `base === "shell"` ternary inside the DEVELOPER_TOOL_BASES branch is unreachable (shell returns earlier); simplified to return "dev_mcp". Gates: biome check, tsc, vite build, 798 unit tests all green. --- .../agents/ui/AgentSessionToolItem.tsx | 78 ++--------- .../agents/ui/ManagedAgentSessionPanel.tsx | 35 +++-- .../ui/agentSessionPanelLayout.test.mjs | 85 ++++++++++++ .../agents/ui/agentSessionPanelLayout.ts | 48 +++++++ .../ui/agentSessionToolItemHelpers.test.mjs | 128 ++++++++++++++++++ .../agents/ui/agentSessionToolSummary.ts | 2 +- .../ui/agentSessionTranscriptGrouping.ts | 3 - .../features/agents/ui/agentSessionUtils.ts | 85 ++++++++++++ .../features/agents/ui/rawEventRail.test.mjs | 42 ++++++ 9 files changed, 415 insertions(+), 91 deletions(-) create mode 100644 desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionPanelLayout.ts create mode 100644 desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs create mode 100644 desktop/src/features/agents/ui/rawEventRail.test.mjs diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 823852142..773c9dd80 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -7,7 +7,11 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import type { TranscriptItem } from "./agentSessionTypes"; import { getBuzzToolInfo } from "./agentSessionToolCatalog"; import { buildCompactToolSummary } from "./agentSessionToolSummary"; -import { asRecord, formatCodeValue, formatDuration } from "./agentSessionUtils"; +import { + formatCodeValue, + getToolDurationDisplay, + isInlineImageData, +} from "./agentSessionUtils"; export function ToolItem({ item, @@ -20,7 +24,7 @@ export function ToolItem({ const canonicalToolName = item.buzzToolName ?? item.toolName; const buzzTool = getBuzzToolInfo(canonicalToolName); const compactSummary = buildCompactToolSummary(item); - const duration = getToolDuration(item); + const duration = getToolDurationDisplay(item); const handleToggle = React.useCallback( (event: React.SyntheticEvent) => { setIsExpanded(event.currentTarget.open); @@ -88,6 +92,10 @@ function compactSummaryTone() { return "text-muted-foreground/60 group-open:text-muted-foreground"; } +function resolveImageSrc(source: string): string { + return isInlineImageData(source) ? source : rewriteRelayUrl(source); +} + function CompactToolSummaryRow({ duration, label, @@ -188,13 +196,6 @@ function CompactMessageSummary({ ); } -function resolveImageSrc(source: string): string { - if (source.startsWith("data:image/")) { - return source; - } - return rewriteRelayUrl(source); -} - function ViewImageToolPreview({ src, title, @@ -294,48 +295,6 @@ function ImageLightbox({ ); } -function getToolDuration(item: Extract) { - if (item.startedAt && item.completedAt) { - return formatDuration(item.startedAt, item.completedAt); - } - - const resultRecord = asRecord(parseToolResultValue(item.result)); - const durationMs = - getToolNumber(resultRecord, ["duration_ms", "durationMs"]) ?? - getToolNumber(resultRecord, ["elapsed_ms", "elapsedMs"]); - return durationMs == null ? null : formatDurationMs(durationMs); -} - -function getToolNumber( - record: Record, - keys: string[], -): number | null { - for (const key of keys) { - const value = record[key]; - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - } - return null; -} - -function formatDurationMs(ms: number) { - if (ms < 0) return null; - const totalSeconds = ms / 1000; - if (totalSeconds < 60) { - return totalSeconds < 10 - ? `${totalSeconds.toFixed(1)}s` - : `${Math.round(totalSeconds)}s`; - } - let minutes = Math.floor(totalSeconds / 60); - let seconds = Math.round(totalSeconds % 60); - if (seconds === 60) { - minutes += 1; - seconds = 0; - } - return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; -} - function ToolDetailBlocks({ args, description, @@ -416,20 +375,3 @@ function ToolCodeBlock({
); } - -function parseToolResultValue(result: string): unknown { - const trimmed = result.trim(); - if (!trimmed) return null; - - try { - const parsed = JSON.parse(trimmed); - if (typeof parsed !== "string") return parsed; - try { - return JSON.parse(parsed); - } catch { - return parsed; - } - } catch { - return null; - } -} diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index aa13ab17e..e3ba558d1 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -21,6 +21,11 @@ import type { ObserverEvent, TranscriptItem, } from "./agentSessionTypes"; +import { + deriveLatestSessionId, + resolveRawRailLayout, + scopeByChannel, +} from "./agentSessionPanelLayout"; import { shorten } from "./agentSessionUtils"; import { useObserverEvents, useAgentTranscript } from "./useObserverEvents"; @@ -58,29 +63,21 @@ export function ManagedAgentSessionPanel({ // Filter transcript items by channelId (lightweight — items now carry channelId) const scopedTranscript = React.useMemo( - () => - channelId - ? transcript.filter((item) => item.channelId === channelId) - : transcript, + () => scopeByChannel(transcript, channelId), [channelId, transcript], ); // Filter raw events by channelId for the RawEventRail const scopedEvents = React.useMemo( - () => - channelId - ? events.filter((event) => event.channelId === channelId) - : events, + () => scopeByChannel(events, channelId), [channelId, events], ); // Derive latestSessionId from channel-scoped events - const latestSessionId = React.useMemo(() => { - for (let i = scopedEvents.length - 1; i >= 0; i--) { - if (scopedEvents[i].sessionId) return scopedEvents[i].sessionId; - } - return null; - }, [scopedEvents]); + const latestSessionId = React.useMemo( + () => deriveLatestSessionId(scopedEvents), + [scopedEvents], + ); return (
@@ -205,7 +204,7 @@ function SessionBody({ ) : (
- {showRaw && rawLayout === "responsive" ? ( - - ) : null} + {rawRail.mode === "side" ? : null}
)} diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs new file mode 100644 index 000000000..96af79d16 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveLatestSessionId, + resolveRawRailLayout, + scopeByChannel, +} from "./agentSessionPanelLayout.ts"; + +// ---- scopeByChannel ---- + +const items = [ + { id: "a", channelId: "channel-1" }, + { id: "b", channelId: "channel-2" }, + { id: "c", channelId: "channel-1" }, +]; + +test("scopeByChannel returns the input unchanged when channelId is null", () => { + assert.equal(scopeByChannel(items, null), items); +}); + +test("scopeByChannel returns the input unchanged when channelId is undefined", () => { + assert.equal(scopeByChannel(items, undefined), items); +}); + +test("scopeByChannel filters items down to the requested channel", () => { + const scoped = scopeByChannel(items, "channel-1"); + assert.deepEqual( + scoped.map((item) => item.id), + ["a", "c"], + ); +}); + +test("scopeByChannel returns an empty array when no item matches", () => { + assert.deepEqual(scopeByChannel(items, "channel-99"), []); +}); + +// ---- deriveLatestSessionId ---- + +test("deriveLatestSessionId returns null for an empty list", () => { + assert.equal(deriveLatestSessionId([]), null); +}); + +test("deriveLatestSessionId returns the last event's sessionId", () => { + const events = [ + { seq: 1, sessionId: "sess-1" }, + { seq: 2, sessionId: "sess-2" }, + ]; + assert.equal(deriveLatestSessionId(events), "sess-2"); +}); + +test("deriveLatestSessionId skips trailing events without a sessionId", () => { + const events = [ + { seq: 1, sessionId: "sess-1" }, + { seq: 2, sessionId: null }, + { seq: 3, sessionId: undefined }, + ]; + assert.equal(deriveLatestSessionId(events), "sess-1"); +}); + +test("deriveLatestSessionId returns null when no event carries a sessionId", () => { + const events = [{ seq: 1, sessionId: null }, { seq: 2 }]; + assert.equal(deriveLatestSessionId(events), null); +}); + +// ---- resolveRawRailLayout (raw-ACP view toggle) ---- + +test("resolveRawRailLayout hides the rail when showRaw is off", () => { + assert.deepEqual(resolveRawRailLayout(false, "responsive"), { + mode: "hidden", + }); + assert.deepEqual(resolveRawRailLayout(false, "exclusive"), { + mode: "hidden", + }); +}); + +test("resolveRawRailLayout renders the rail exclusively when toggled on in exclusive layout", () => { + assert.deepEqual(resolveRawRailLayout(true, "exclusive"), { + mode: "exclusive", + }); +}); + +test("resolveRawRailLayout renders the rail beside the transcript in responsive layout", () => { + assert.deepEqual(resolveRawRailLayout(true, "responsive"), { mode: "side" }); +}); diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.ts b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts new file mode 100644 index 000000000..e0aaeb203 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts @@ -0,0 +1,48 @@ +import type { ObserverEvent } from "./agentSessionTypes"; + +/** + * Filter transcript items or raw observer events down to a single channel. + * A null `channelId` means "no scoping" — the input is returned as-is. + */ +export function scopeByChannel( + items: readonly T[], + channelId: string | null | undefined, +): T[] { + if (!channelId) return items as T[]; + return items.filter((item) => item.channelId === channelId); +} + +/** + * Derive the most recent session id from a list of observer events by + * scanning from the end. Returns null when no event carries a sessionId. + */ +export function deriveLatestSessionId( + events: readonly ObserverEvent[], +): string | null { + for (let i = events.length - 1; i >= 0; i--) { + const sessionId = events[i]?.sessionId; + if (sessionId) return sessionId; + } + return null; +} + +export type RawRailLayout = + | { mode: "hidden" } + | { mode: "exclusive" } + | { mode: "side" }; + +/** + * Decide how the raw-ACP event rail should be rendered relative to the + * transcript: + * - `hidden` — raw view is off + * - `exclusive` — raw rail replaces the transcript entirely + * - `side` — raw rail renders alongside the transcript (responsive) + */ +export function resolveRawRailLayout( + showRaw: boolean, + rawLayout: "responsive" | "exclusive", +): RawRailLayout { + if (!showRaw) return { mode: "hidden" }; + if (rawLayout === "exclusive") return { mode: "exclusive" }; + return { mode: "side" }; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs new file mode 100644 index 000000000..52b548ec6 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + formatDurationMs, + getToolDurationDisplay, + isInlineImageData, + parseToolResultValue, +} from "./agentSessionUtils.ts"; + +// ---- isInlineImageData (dual-layer image-scheme security guard) ---- + +test("isInlineImageData accepts data:image/ URIs", () => { + const dataUri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC"; + assert.equal(isInlineImageData(dataUri), true); +}); + +test("isInlineImageData rejects non-image data: schemes (no passthrough widening)", () => { + // A non-image data: URI must NOT be treated as a safe inline image — + // it has to fall through to the relay rewrite path. + assert.equal( + isInlineImageData( + "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", + ), + false, + ); + assert.equal(isInlineImageData("data:application/json;base64,e30="), false); +}); + +test("isInlineImageData rejects relay-relative and absolute media URLs", () => { + assert.equal(isInlineImageData("/media/abc123.png"), false); + assert.equal(isInlineImageData("https://relay.example/media/abc.png"), false); +}); + +// ---- formatDurationMs ---- + +test("formatDurationMs returns null for negative input", () => { + assert.equal(formatDurationMs(-1), null); +}); + +test("formatDurationMs renders sub-10s with one decimal", () => { + assert.equal(formatDurationMs(400), "0.4s"); + assert.equal(formatDurationMs(9900), "9.9s"); +}); + +test("formatDurationMs rounds 10s..60s to whole seconds", () => { + assert.equal(formatDurationMs(12300), "12s"); + assert.equal(formatDurationMs(59400), "59s"); +}); + +test("formatDurationMs renders minutes and seconds", () => { + assert.equal(formatDurationMs(90000), "1m 30s"); + assert.equal(formatDurationMs(120000), "2m"); +}); + +test("formatDurationMs carries a rounded 60s into the next minute", () => { + // 89.7s rounds the seconds component to 60, which must carry to 1m 30s + assert.equal(formatDurationMs(89700), "1m 30s"); +}); + +// ---- parseToolResultValue (JSON double-parse) ---- + +test("parseToolResultValue returns null for empty/whitespace", () => { + assert.equal(parseToolResultValue(""), null); + assert.equal(parseToolResultValue(" "), null); +}); + +test("parseToolResultValue parses a JSON object", () => { + assert.deepEqual(parseToolResultValue('{"duration_ms":123}'), { + duration_ms: 123, + }); +}); + +test("parseToolResultValue unwraps a double-encoded JSON string", () => { + // The result is a JSON string that itself contains JSON. + const doubleEncoded = JSON.stringify(JSON.stringify({ ok: true })); + assert.deepEqual(parseToolResultValue(doubleEncoded), { ok: true }); +}); + +test("parseToolResultValue returns the inner string when it is not JSON", () => { + const wrapped = JSON.stringify("plain text"); + assert.equal(parseToolResultValue(wrapped), "plain text"); +}); + +test("parseToolResultValue returns null for invalid JSON", () => { + assert.equal(parseToolResultValue("not json {"), null); +}); + +// ---- getToolDurationDisplay (fallback chain) ---- + +const startedAt = "2026-06-14T19:00:00.000Z"; +const completedAt = "2026-06-14T19:00:02.000Z"; + +test("getToolDurationDisplay prefers start/complete timestamps", () => { + assert.equal( + getToolDurationDisplay({ startedAt, completedAt, result: "" }), + "2.0s", + ); +}); + +test("getToolDurationDisplay falls back to duration_ms in the result payload", () => { + assert.equal( + getToolDurationDisplay({ + startedAt: null, + completedAt: null, + result: JSON.stringify({ duration_ms: 3500 }), + }), + "3.5s", + ); +}); + +test("getToolDurationDisplay falls back to elapsed_ms when duration_ms absent", () => { + assert.equal( + getToolDurationDisplay({ + result: JSON.stringify({ elapsed_ms: 65000 }), + }), + "1m 5s", + ); +}); + +test("getToolDurationDisplay returns null when no duration is available", () => { + assert.equal(getToolDurationDisplay({ result: "" }), null); + assert.equal( + getToolDurationDisplay({ result: JSON.stringify({ other: 1 }) }), + null, + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index 02ea90e2f..1513e1017 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -110,7 +110,7 @@ function classifyDeveloperToolName( if (base === "postcompact") return "post_compact_hook"; if (DEVELOPER_TOOL_BASES.has(base)) { - return base === "shell" ? "shell" : "dev_mcp"; + return "dev_mcp"; } if (normalized.includes("buzz_dev_mcp")) { diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index d350ca299..fd2877f27 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -84,9 +84,6 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { segments.push({ kind: "item", item }); continue; } - if (isSetupLifecycle(item)) { - continue; - } segments.push({ kind: "item", item }); } diff --git a/desktop/src/features/agents/ui/agentSessionUtils.ts b/desktop/src/features/agents/ui/agentSessionUtils.ts index 19d915ec8..39cec560e 100644 --- a/desktop/src/features/agents/ui/agentSessionUtils.ts +++ b/desktop/src/features/agents/ui/agentSessionUtils.ts @@ -64,6 +64,91 @@ export function asRecord(value: unknown): Record { : {}; } +/** + * True when a tool image source is an inline `data:image/` URI that should be + * rendered as-is. This is the dual-layer image-scheme guard: only the + * `data:image/` prefix is treated as a safe passthrough — every other scheme + * (including other `data:` subtypes) must be routed through the relay rewriter. + * Never widen this beyond `data:image/`. + */ +export function isInlineImageData(source: string): boolean { + return source.startsWith("data:image/"); +} + +function getToolNumber( + record: Record, + keys: string[], +): number | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + return null; +} + +/** Format a millisecond duration; negative input yields null. */ +export function formatDurationMs(ms: number): string | null { + if (ms < 0) return null; + const totalSeconds = ms / 1000; + if (totalSeconds < 60) { + return totalSeconds < 10 + ? `${totalSeconds.toFixed(1)}s` + : `${Math.round(totalSeconds)}s`; + } + let minutes = Math.floor(totalSeconds / 60); + let seconds = Math.round(totalSeconds % 60); + if (seconds === 60) { + minutes += 1; + seconds = 0; + } + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +} + +/** + * Parse a tool result string into a value. Handles the double-encoding case + * where a JSON string itself contains JSON. Returns null on empty or invalid + * input. + */ +export function parseToolResultValue(result: string): unknown { + const trimmed = result.trim(); + if (!trimmed) return null; + + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "string") return parsed; + try { + return JSON.parse(parsed); + } catch { + return parsed; + } + } catch { + return null; + } +} + +/** + * Resolve a tool's display duration. Prefers the start/complete timestamps, + * then falls back to `duration_ms`/`elapsed_ms` fields inside the parsed + * result payload. + */ +export function getToolDurationDisplay(item: { + startedAt?: string | null; + completedAt?: string | null; + result: string; +}): string | null { + if (item.startedAt && item.completedAt) { + return formatDuration(item.startedAt, item.completedAt); + } + + const resultRecord = asRecord(parseToolResultValue(item.result)); + const durationMs = + getToolNumber(resultRecord, ["duration_ms", "durationMs"]) ?? + getToolNumber(resultRecord, ["elapsed_ms", "elapsedMs"]); + return durationMs == null ? null : formatDurationMs(durationMs); +} + export function asString(value: unknown): string | null { return typeof value === "string" ? value : null; } diff --git a/desktop/src/features/agents/ui/rawEventRail.test.mjs b/desktop/src/features/agents/ui/rawEventRail.test.mjs new file mode 100644 index 000000000..7750fdac2 --- /dev/null +++ b/desktop/src/features/agents/ui/rawEventRail.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { describeRawEvent } from "./agentSessionTranscriptHelpers.ts"; + +function rawEvent(overrides = {}) { + return { + seq: 1, + kind: "acp", + sessionId: "sess-1", + channelId: "channel-1", + payload: {}, + ...overrides, + }; +} + +test("describeRawEvent surfaces the session/update sessionUpdate label", () => { + const event = rawEvent({ + payload: { + method: "session/update", + params: { update: { sessionUpdate: "agent_message_chunk" } }, + }, + }); + assert.equal(describeRawEvent(event), "agent_message_chunk"); +}); + +test("describeRawEvent falls back to the method when session/update lacks an update label", () => { + const event = rawEvent({ + payload: { method: "session/update", params: {} }, + }); + assert.equal(describeRawEvent(event), "session/update"); +}); + +test("describeRawEvent uses the method for non-session/update payloads", () => { + const event = rawEvent({ payload: { method: "session/prompt" } }); + assert.equal(describeRawEvent(event), "session/prompt"); +}); + +test("describeRawEvent falls back to the event kind when no method is present", () => { + const event = rawEvent({ kind: "acp_parse_error", payload: {} }); + assert.equal(describeRawEvent(event), "acp_parse_error"); +});