diff --git a/VISION_ACTIVITY.md b/VISION_ACTIVITY.md new file mode 100644 index 000000000..d1d1e0ac5 --- /dev/null +++ b/VISION_ACTIVITY.md @@ -0,0 +1,65 @@ +# Vision: The Agent Activity Feed + +## The Problem + +When you delegate work to an agent, you are trusting a process you cannot see. The activity feed is the window into that process — but a window is only useful if you can read it at a glance. A raw input/output dump is not a window; it is a transcript you have to decode. It forces you to *parse* before you can *judge*. + +We wanted a feed you supervise the way you supervise a capable teammate: skim for progress, trust the routine, and catch the one thing that needs you — without reading every line. + +## Who It Serves + +A developer supervising a delegate. They are not watching for entertainment; they are deciding whether to intervene. Every item in the feed earns its pixels by answering one of three questions: + +- **Comprehension** — *what is it doing, and why?* +- **Confidence** — *is it going well, or is it stuck or wrong?* +- **Control** — *do I need to step in, and where?* + +A feed that answers these instantly converts a stream of events into a sense of trajectory. A feed that does not is just noise with a scrollbar. + +## The Governing Frame: Verb, Object, Outcome + +Every meaningful item is a sentence: **the agent did [verb] to [object] → [outcome].** + +> "Sent a message to #design." · "Edited `runtime.rs` (+12/−3)." · "Reacted 👍 to Marge's review." · "Ran tests → 1248 passed." + +The feed's job is to surface verb, object, and outcome immediately, and to push the supporting detail — full arguments, raw output, the unabridged diff — into progressive disclosure. You read the sentence; you expand only when the sentence makes you want to. + +## The Render Classes + +Every item resolves to one of twelve presentation classes, organized by how often they are read and how much consequence they carry: + +**The spine — read constantly.** Message (the agent's voice), Buzz relay op (acting on the platform), File-edit (the actual code work), Shell command (the agent's hands), and Tool status & turn lifecycle (the heartbeat). If these are unclear, the feed has failed. + +**High-value context — consulted to judge correctness.** Thought (reasoning, on tap), Plan/Todo (the roadmap and progress bar), Permission (the control gate), and Error (the stop sign). + +**Ambient safety net — rarely read, but must exist.** Generic tool (the honest fallback), Raw rail (ground truth on demand), and Suppressed noise (what we deliberately do not render). + +These are not a wish list. They are the complete taxonomy: every event the agent can emit lands in exactly one class, and the last three guarantee there is always a floor. + +## Design Principles + +- **Semantics over transport.** Render *what the agent did*, not *which API it used*. A message sent through an MCP tool and the same message sent through a shell `buzz` command render as the identical card. How the agent reached the relay is plumbing; what it did is the contract. + +- **Outcome-first.** Lead with success, failure, or result. The reader decides in under a second whether to expand. The raw dump is the fallback, never the headline. + +- **Mutate in place.** A running action updates its own row from pending to executing to done or failed. One action is one item, not a trail of duplicated status lines. + +- **Never go dark.** The absence of an event is itself information. Silence, idle, and timeout are *rendered states* — "waiting…", "timed out" — never an empty void. This mirrors the rule we hold our agents to: if you didn't show it, it didn't happen. + +- **Failures rise; reads recede.** Salience tracks consequence. Admin actions, writes, and errors are loud. Reads and reasoning are quiet. A buried error is a broken feed. + +- **Resolve references.** Show "#design", "Marge's message", a filename — never a raw event id or pubkey. The reader thinks in names, not hashes. + +- **Coalesce streams.** Chunked text becomes one item. The developer reads a message, not a packet trace. + +- **Honesty over guessing.** A recognized operation gets a semantic card. An unrecognized one degrades to a clean, truthful, generic row. We never fabricate semantics to look richer than we are. + +- **Polished by default, raw on demand.** Curation is the product; the raw rail is the safety net. The toggle between them is a zoom level on the same truth, not a different feed. + +## What This Earns + +The feed's real job is to **earn delegation.** Visible progress, visible consent, and visible outcomes compound: each turn you watch go well makes you trust the agent with a larger one. Deciding what *not* to show — suppressing heartbeats and internal chatter — is as much a feature as deciding what to show, because suppression is what makes the signal legible. + +A feed built this way is protocol-honest at its base: any compliant agent's messages, thoughts, tool calls, and turns become first-class items regardless of which tools it runs. The Buzz-specific richness — semantic relay cards, the buzz-CLI parser, diff rendering — is a layer of enrichment on top, not a requirement underneath. Non-Buzz agents get a correct, legible feed; Buzz agents get a native one. + +Two altitudes of the same truth. Polished for judgment, raw for debugging. The window stays a window. diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 60768de9b..41df4bde4 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -73,6 +73,8 @@ const overrides = new Map([ // unify refactor followup. +26 for resolve_effective_prompt_model_provider // re-introduced after 826d735fe removal (config-bridge caller still needs it). // PGID resolution helper + PID-recycling safety guard added for orphan sweep. + // activity-feed threads avatar_url into build_managed_agent_summary for the + // assistant-bubble pinned snapshot. ["src-tauri/src/managed_agents/runtime.rs", 2150], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the @@ -121,7 +123,7 @@ const overrides = new Map([ ["src/features/channels/readState/readStateManager.ts", 1030], // Shared UI was added to this guard after splitting globals/markdown so // large shared renderers cannot grow further while follow-up splits land. - ["src/shared/ui/markdown.tsx", 2082], + ["src/shared/ui/markdown.tsx", 2119], ["src/shared/ui/VideoPlayer.tsx", 2199], ["src/shared/ui/sidebar.tsx", 1042], // Option C databricks-model-discovery: parse/HTTP logic moved to buzz-agent @@ -129,6 +131,9 @@ const overrides = new Map([ // File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess // fallback. Queued to split into dedicated discovery modules. ["src-tauri/src/commands/agent_models.rs", 1066], + // Kept activity-feed design fixture: realistic prompt context and tool-heavy + // chatter for render-class test/reference coverage. Queued to split with the + // rest of this list if it grows further. ]); await runFileSizeCheck({ diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 585ffea26..de38c3b1b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1539,6 +1539,7 @@ pub fn build_managed_agent_summary( max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, system_prompt: record.system_prompt.clone(), + avatar_url: record.avatar_url.clone(), model: record.model.clone(), provider: record.provider.clone(), persona_out_of_date, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 3ff56fcc4..c3fb09709 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -268,6 +268,7 @@ pub struct ManagedAgentSummary { pub max_turn_duration_seconds: Option, pub parallelism: u32, pub system_prompt: Option, + pub avatar_url: Option, pub model: Option, /// LLM inference provider, from the agent's pinned record snapshot. pub provider: Option, diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index f2aa0247c..c3d3fb3ec 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -1,428 +1 @@ -import * as React from "react"; -import { ArrowUpRight, ChevronDown, Wrench } from "lucide-react"; - -import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; -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 { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -import { UserAvatar } from "@/shared/ui/UserAvatar"; -import type { TranscriptItem } from "./agentSessionTypes"; -import { - formatToolTitle, - getBuzzToolInfo, - getToolStatusDisplay, -} from "./agentSessionToolCatalog"; -import { - asRecord, - formatCodeValue, - formatDuration, - formatTranscriptTime, - getResultArray, - getToolString, - getToolStringList, - shortenMiddle, -} from "./agentSessionUtils"; - -export function ToolItem({ - item, -}: { - item: Extract; -}) { - const [isExpanded, setIsExpanded] = React.useState(false); - const status = getToolStatusDisplay(item.status, item.isError); - const hasArgs = Object.keys(item.args).length > 0; - const hasResult = item.result.trim().length > 0; - const canonicalToolName = item.buzzToolName ?? item.toolName; - const buzzTool = getBuzzToolInfo(canonicalToolName); - const ToolIcon = buzzTool?.icon ?? Wrench; - const showStatus = status.state !== "output-available"; - const toolTitle = formatToolTitle(canonicalToolName, item.title); - const handleToggle = React.useCallback( - (event: React.SyntheticEvent) => { - setIsExpanded(event.currentTarget.open); - }, - [], - ); - - return ( -
-
- - {ToolIcon ? ( - - ) : null} - - {toolTitle} - - {buzzTool ? ( - - ) : null} - {showStatus ? ( - - - {status.label} - - ) : null} - - - - - -
-
- ); -} - -function ToolDetailBlocks({ - args, - description, - hasArgs, - hasResult, - isError, - result, -}: { - args: Record; - description?: string; - hasArgs: boolean; - hasResult: boolean; - isError: boolean; - result: string; -}) { - return ( -
- {description ? ( -

- {description} -

- ) : null} - {hasArgs ? ( - - ) : null} - {hasResult ? ( - - ) : null} - {!hasArgs && !hasResult ? ( -

- Waiting for tool details. -

- ) : null} -
- ); -} - -function ToolCodeBlock({ - label, - tone, - value, -}: { - label: string; - tone: "muted" | "error"; - value: string; -}) { - return ( -
-

- {label} -

-
-        {formatCodeValue(value)}
-      
-
- ); -} - -const toolFullDateTimeFormat = new Intl.DateTimeFormat(undefined, { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", -}); - -function ToolTimestamp({ - item, -}: { - item: Extract; -}) { - const time = formatTranscriptTime(item.timestamp); - if (!time) return null; - const duration = - item.startedAt && item.completedAt - ? formatDuration(item.startedAt, item.completedAt) - : null; - const date = new Date(item.timestamp); - const fullDateTime = Number.isNaN(date.getTime()) - ? item.timestamp - : toolFullDateTimeFormat.format(date); - return ( - - - - {time} - {duration ? ` · ${duration}` : null} - - - {fullDateTime} - - ); -} - -function BuzzToolInlineAction({ - args, - result, -}: { - args: Record; - result: string; -}) { - const { channels } = useChannelNavigation(); - const { goChannel } = useAppNavigation(); - const resultValue = React.useMemo( - () => parseToolResultValue(result), - [result], - ); - const resultRecord = asRecord(resultValue); - const channelId = - getToolString(args, ["channel_id", "channelId"]) ?? - getToolString(resultRecord, ["channel_id", "channelId"]); - const pubkeys = React.useMemo( - () => getToolStringList(args, ["pubkeys", "pubkey"]), - [args], - ); - const profilesQuery = useUsersBatchQuery(pubkeys, { - enabled: pubkeys.length > 0, - }); - const profiles = profilesQuery.data?.profiles; - const openChannel = React.useCallback( - (messageId?: string) => { - if (!channelId) return; - void goChannel(channelId, messageId ? { messageId } : undefined); - }, - [channelId, goChannel], - ); - const action = React.useMemo( - () => - getBuzzToolInlineAction({ - args, - channelId, - channels, - openChannel, - profiles, - resultValue, - }), - [args, channelId, channels, openChannel, profiles, resultValue], - ); - - if (!action) { - return null; - } - - 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; - - try { - const parsed = JSON.parse(trimmed); - if (typeof parsed !== "string") return parsed; - try { - return JSON.parse(parsed); - } catch { - return parsed; - } - } catch { - return null; - } -} - -function getChannelChipLabel(channels: Channel[], channelId: string) { - const channel = channels.find((candidate) => candidate.id === channelId); - return channel ? `#${channel.name}` : `#${shortenMiddle(channelId, 22)}`; -} +export { ToolItem } from "./AgentSessionToolItem/ToolItem"; diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx new file mode 100644 index 000000000..9b786d8ab --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx @@ -0,0 +1,102 @@ +import * as React from "react"; +import { CheckCheck } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { TranscriptTimestamp } from "../activityRenderClasses/TranscriptTimestamp"; +import { compactSummaryTone } from "./CompactToolSummaryRow"; +import type { SentMessageLink } from "./messageLinks"; +import { SentMessageContextDialog } from "./SentMessageContextDialog"; + +export function CompactMessageSummary({ + args, + avatarUrl, + description, + displayName, + duration, + hasArgs, + hasResult, + isError, + label, + messageLink, + preview, + result, + timestamp, +}: { + args: Record; + avatarUrl: string | null; + description?: string; + displayName: string; + duration: string | null; + hasArgs: boolean; + hasResult: boolean; + isError: boolean; + label: string; + messageLink: SentMessageLink | null; + preview: string | null; + result: string; + timestamp: string; +}) { + const [detailsOpen, setDetailsOpen] = React.useState(false); + const mutedTone = compactSummaryTone(); + return ( + <> +
+ +
+
+

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

+
+
+ + +
+
+
+ + + ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx new file mode 100644 index 000000000..825adff68 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactToolSummaryRow.tsx @@ -0,0 +1,144 @@ +import * as React from "react"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import type { AgentActivityAction } from "../agentSessionTypes"; +import type { CompactFileEditSummary } from "../agentSessionToolSummary"; +import { isInlineImageData } from "../agentSessionUtils"; +import { + ActivityRowLabel, + splitActivityRowLabel, + type ActivityRowLabelParts, +} from "../activityRenderClasses/ActivityRow"; + +export function compactSummaryTone() { + return "text-muted-foreground/60 group-open:text-foreground"; +} + +export function CompactToolSummaryRow({ + action, + duration, + fileEditSummary, + label, + preview, + thumbnailSrc, +}: { + action: AgentActivityAction | null; + duration: string | null; + fileEditSummary: CompactFileEditSummary | null; + label: string; + preview: string | null; + thumbnailSrc: string | null; +}) { + const [thumbnailFailed, setThumbnailFailed] = React.useState(false); + const mutedTone = compactSummaryTone(); + const resolvedThumbnail = React.useMemo(() => { + if (!thumbnailSrc || thumbnailFailed) return null; + return resolveImageSrc(thumbnailSrc); + }, [thumbnailFailed, thumbnailSrc]); + const actionLabel = fileEditSummary + ? null + : getCompactToolActionLabel(action, label, preview); + + return ( + <> + {fileEditSummary ? ( + + ) : actionLabel ? ( + + ) : ( + + {label} + + )} + {!fileEditSummary && resolvedThumbnail ? ( + setThumbnailFailed(true)} + src={resolvedThumbnail} + title={preview ?? undefined} + /> + ) : !fileEditSummary && !actionLabel && preview ? ( + + {preview} + + ) : null} + {duration ? ( + {duration} + ) : null} + + + ); +} + +function getCompactToolActionLabel( + action: AgentActivityAction | null, + label: string, + preview: string | null, +): (ActivityRowLabelParts & { title?: string }) | null { + if (action) { + const object = action.object ?? preview ?? undefined; + return { + verb: action.verb, + object, + title: typeof object === "string" ? object : undefined, + }; + } + + const parts = splitActivityRowLabel(label); + if (!parts) return null; + + if (!preview) return parts; + + if ( + label === "Ran command" || + label === "Read file" || + label === "Updated todos" || + label === "Viewed image" + ) { + return { verb: parts.verb, object: preview, title: preview }; + } + + return parts; +} + +function CompactFileEditSummaryView({ + summary, +}: { + summary: CompactFileEditSummary; +}) { + return ( + + ); +} + +function resolveImageSrc(source: string): string { + return isInlineImageData(source) ? source : rewriteRelayUrl(source); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/SentMessageContextDialog.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/SentMessageContextDialog.tsx new file mode 100644 index 000000000..1ffe7036d --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/SentMessageContextDialog.tsx @@ -0,0 +1,186 @@ +import * as React from "react"; +import { CheckCheck, ChevronDown } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { formatCodeValue } from "../agentSessionUtils"; + +export function SentMessageContextDialog({ + args, + description, + duration, + hasArgs, + hasResult, + isError, + label, + onOpenChange, + open, + preview, + result, +}: { + args: Record; + description?: string; + duration: string | null; + hasArgs: boolean; + hasResult: boolean; + isError: boolean; + label: string; + onOpenChange: (open: boolean) => void; + open: boolean; + preview: string | null; + result: string; +}) { + const sections = buildSentMessageContextSections({ + args, + description, + hasArgs, + hasResult, + isError, + preview, + result, + }); + + return ( + + +
+ + Sent message context + + + {label} + {duration ? {duration} : null} + + +
+ +
+
+
+
+ ); +} + +type SentMessageContextSection = { + body: string; + title: string; +}; + +function buildSentMessageContextSections({ + args, + description, + hasArgs, + hasResult, + isError, + preview, + result, +}: { + args: Record; + description?: string; + hasArgs: boolean; + hasResult: boolean; + isError: boolean; + preview: string | null; + result: string; +}): SentMessageContextSection[] { + const sections: SentMessageContextSection[] = []; + if (preview) { + sections.push({ title: "Message", body: preview }); + } + if (description) { + sections.push({ title: "Tool", body: description }); + } + if (hasArgs) { + sections.push({ + title: "Parameters", + body: JSON.stringify(args, null, 2), + }); + } + if (hasResult) { + sections.push({ + title: isError ? "Error" : "Result", + body: formatCodeValue(result), + }); + } + if (sections.length === 0) { + sections.push({ + title: "Status", + body: "Waiting for tool details.", + }); + } + return sections; +} + +function SentMessageContextSections({ + sections, +}: { + sections: SentMessageContextSection[]; +}) { + return ( +
+ {sections.map((section) => ( + + ))} +
+ ); +} + +function SentMessageContextSectionAccordion({ + section, +}: { + section: SentMessageContextSection; +}) { + const [open, setOpen] = React.useState(false); + const body = section.body.trim(); + + return ( +
+ +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ShellCommandBlock.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ShellCommandBlock.tsx new file mode 100644 index 000000000..198762262 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ShellCommandBlock.tsx @@ -0,0 +1,31 @@ +import { Terminal } from "lucide-react"; + +import { parseShellToolOutput } from "../agentSessionUtils"; + +export function ShellCommandBlock({ + command, + result, +}: { + command: string; + result: string; +}) { + const output = parseShellToolOutput(result); + const stdout = output.stdout.trimEnd(); + + return ( +
+

+ + {command} +

+ {stdout ? ( +
+          {stdout}
+        
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx new file mode 100644 index 000000000..736a24622 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/TodoToolSummary.tsx @@ -0,0 +1,178 @@ +import type { TranscriptItem } from "../agentSessionTypes"; +import type { CompactToolSummary } from "../agentSessionToolSummary"; +import { + asRecord, + formatTranscriptTimestampTitle, + getToolString, + parseToolResultValue, +} from "../agentSessionUtils"; +import { + ActivityRow, + ActivityRowContent, + ActivityRowLabel, +} from "../activityRenderClasses/ActivityRow"; + +type TodoDisplayItem = { + checked: boolean; + text: string; +}; + +export function TodoToolSummary({ + duration, + fallbackPreview, + item, +}: { + duration: string | null; + fallbackPreview: string | null; + item: Extract; +}) { + const todos = buildTodoDisplayItems(item.args, item.result, fallbackPreview); + const actionLabel = { + verb: "Updated", + object: fallbackPreview ?? "todos", + }; + + return ( + + + {duration ? ( + + {duration} + + ) : null} + + {todos.length > 0 ? ( +
+ {todos.map((todo, index) => ( + + ))} +
+ ) : ( +

No todos.

+ )} +
+
+ ); +} + +export function isTodoSummary(summary: CompactToolSummary) { + return ( + summary.descriptor.groupKey === "plan:todo" || + summary.descriptor.operation === "todo" + ); +} + +function TodoCheckboxRow({ todo }: { todo: TodoDisplayItem }) { + return ( +
+ + {todo.text} +
+ ); +} + +function buildTodoDisplayItems( + args: Record, + result: string, + fallbackPreview: string | null, +): TodoDisplayItem[] { + const argTodos = extractTodoItemsFromArgs(args); + if (argTodos.length > 0) { + return argTodos; + } + + const resultTodos = extractTodoItemsFromResult(result); + if (resultTodos.length > 0) { + return resultTodos; + } + + return fallbackPreview && fallbackPreview !== "empty list" + ? [{ checked: false, text: fallbackPreview }] + : []; +} + +function extractTodoItemsFromArgs( + args: Record, +): TodoDisplayItem[] { + const todos = args.todos; + if (!Array.isArray(todos)) { + return []; + } + + return todos.flatMap((todo) => { + if (!todo || typeof todo !== "object") { + return []; + } + + const record = asRecord(todo); + const text = getToolString(record, ["text", "content", "label", "title"]); + if (!text) { + return []; + } + + return [ + { + checked: getTodoChecked(record), + text, + }, + ]; + }); +} + +function extractTodoItemsFromResult(result: string): TodoDisplayItem[] { + const resultText = getTodoResultText(result); + if (!resultText) { + return []; + } + + return resultText.split(/\r?\n/).flatMap((line) => { + const match = line.match(/^\s*[-*]\s+\[([ xX])]\s+(.+?)\s*$/); + if (!match) { + return []; + } + + return [ + { + checked: match[1].toLowerCase() === "x", + text: match[2], + }, + ]; + }); +} + +function getTodoResultText(result: string): string { + const parsed = parseToolResultValue(result); + if (typeof parsed === "string") { + return parsed; + } + + const record = asRecord(parsed); + return getToolString(record, ["stdout", "result", "text"]) ?? result; +} + +function getTodoChecked(record: Record) { + if (typeof record.done === "boolean") { + return record.done; + } + if (typeof record.checked === "boolean") { + return record.checked; + } + + const status = getToolString(record, ["status"])?.toLowerCase(); + return status === "completed" || status === "done"; +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolDetailBlocks.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolDetailBlocks.tsx new file mode 100644 index 000000000..440188110 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolDetailBlocks.tsx @@ -0,0 +1,102 @@ +import { cn } from "@/shared/lib/cn"; +import type { FileEditDiff } from "../agentSessionFileEditDiff"; +import { FileEditDiffBlock, hasFileEditLineDiff } from "../FileEditDiffView"; +import { formatCodeValue } from "../agentSessionUtils"; +import { ShellCommandBlock } from "./ShellCommandBlock"; +import { ViewImageToolPreview } from "./ViewImageToolPreview"; + +export function ToolDetailBlocks({ + args, + description, + fileEditDiff, + hasArgs, + hasResult, + imagePreview, + isError, + result, + shellCommand, +}: { + args: Record; + description?: string; + fileEditDiff: FileEditDiff | null; + hasArgs: boolean; + hasResult: boolean; + imagePreview: { src: string | null; title: string | null } | null; + isError: boolean; + result: string; + shellCommand: string | null; +}) { + const showFileEditDiff = + fileEditDiff && hasFileEditLineDiff(fileEditDiff) && !isError; + const showShellCommand = shellCommand != null && !showFileEditDiff; + const showParameters = hasArgs && !showFileEditDiff; + + return ( +
+ {description ? ( +

+ {description} +

+ ) : null} + {imagePreview?.src ? ( + + ) : null} + {showShellCommand ? ( + + ) : showParameters ? ( + + ) : null} + {!showShellCommand && hasResult ? ( + showFileEditDiff ? ( + + ) : ( + + ) + ) : null} + {!showShellCommand && !showParameters && !hasResult ? ( +

+ Waiting for tool details. +

+ ) : null} +
+ ); +} + +function ToolCodeBlock({ + label, + tone, + value, +}: { + label: string; + tone: "muted" | "error"; + value: string; +}) { + return ( +
+

+ {label} +

+
+        {formatCodeValue(value)}
+      
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx new file mode 100644 index 000000000..5b4c7e4fb --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ToolItem.tsx @@ -0,0 +1,159 @@ +import * as React from "react"; + +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { TranscriptItem } from "../agentSessionTypes"; +import { getBuzzToolInfo } from "../agentSessionToolCatalog"; +import { buildCompactToolSummary } from "../agentSessionToolSummary"; +import type { AgentTranscriptIdentityProps } from "../activityRenderClasses/types"; +import { + formatTranscriptTimestampTitle, + getToolDurationDisplay, + getToolString, +} from "../agentSessionUtils"; +import { CompactMessageSummary } from "./CompactMessageSummary"; +import { + CompactToolSummaryRow, + compactSummaryTone, +} from "./CompactToolSummaryRow"; +import { getSentMessageLink } from "./messageLinks"; +import { isTodoSummary, TodoToolSummary } from "./TodoToolSummary"; +import { ToolDetailBlocks } from "./ToolDetailBlocks"; + +export function ToolItem({ + agentAvatarUrl, + agentName, + agentPubkey, + item, + profiles, +}: AgentTranscriptIdentityProps & { + item: Extract; + profiles?: UserProfileLookup; +}) { + const [isExpanded, setIsExpanded] = React.useState(false); + const hasArgs = Object.keys(item.args).length > 0; + const hasResult = item.result.trim().length > 0; + const canonicalToolName = item.buzzToolName ?? item.toolName; + const buzzTool = getBuzzToolInfo(canonicalToolName); + const compactSummary = buildCompactToolSummary(item); + const duration = getToolDurationDisplay(item); + const messageLink = getSentMessageLink(item); + const timestampTitle = formatTranscriptTimestampTitle(item.timestamp); + const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; + const agentLabel = resolveUserLabel({ + pubkey: agentPubkey, + fallbackName: agentName, + profiles, + preferResolvedSelfLabel: true, + }); + const agentResolvedAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; + const handleToggle = React.useCallback( + (event: React.SyntheticEvent) => { + setIsExpanded(event.currentTarget.open); + }, + [], + ); + + if (compactSummary.presentation === "message") { + return ( +
+ +
+ ); + } + + if (isTodoSummary(compactSummary)) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + + + + +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/ViewImageToolPreview.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/ViewImageToolPreview.tsx new file mode 100644 index 000000000..78d37dcb7 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/ViewImageToolPreview.tsx @@ -0,0 +1,108 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; + +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { isInlineImageData } from "../agentSessionUtils"; + +export function ViewImageToolPreview({ + src, + title, +}: { + src: string; + title: string | null; +}) { + const [lightboxOpen, setLightboxOpen] = React.useState(false); + const [imageFailed, setImageFailed] = React.useState(false); + const resolvedSrc = React.useMemo(() => resolveImageSrc(src), [src]); + const alt = title ?? "Viewed image"; + + if (imageFailed) { + return null; + } + + return ( + <> + {/* biome-ignore lint/a11y/useKeyWithClickEvents: opens lightbox on click */} + {alt} setLightboxOpen(true)} + onError={() => setImageFailed(true)} + src={resolvedSrc} + title={title ?? undefined} + /> + + + ); +} + +function ImageLightbox({ + alt, + onOpenChange, + open, + src, +}: { + alt: string; + onOpenChange: (open: boolean) => void; + open: boolean; + src: string; +}) { + return ( + + + + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + > + + {alt} + + + Full-size image preview. Press Escape or click outside the image to + close. + + + {alt} + + + + + + + ); +} + +function resolveImageSrc(source: string): string { + return isInlineImageData(source) ? source : rewriteRelayUrl(source); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/messageLinks.ts b/desktop/src/features/agents/ui/AgentSessionToolItem/messageLinks.ts new file mode 100644 index 000000000..ba483ceed --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/messageLinks.ts @@ -0,0 +1,76 @@ +import type { TranscriptItem } from "../agentSessionTypes"; +import { + asRecord, + getToolString, + parseToolResultValue, +} from "../agentSessionUtils"; + +export type SentMessageLink = { + channelId: string; + messageId: string; +}; + +export function getSentMessageLink( + item: Extract, +): SentMessageLink | null { + if (item.status !== "completed" || item.isError) { + return null; + } + + if (item.descriptor?.renderClass !== "message") { + return null; + } + + const channelId = + item.channelId ?? getToolString(item.args, ["channel_id", "channelId"]); + if (!channelId) { + return null; + } + + const resultRecord = getMessageSendResultRecord(item.result); + if (!resultRecord || resultRecord.accepted === false) { + return null; + } + + const messageId = getToolString(resultRecord, [ + "event_id", + "eventId", + "message_id", + "messageId", + ]); + if (!messageId) { + return null; + } + + return { + channelId, + messageId, + }; +} + +function getMessageSendResultRecord( + result: string, +): Record | null { + const parsed = parseToolResultValue(result); + const directRecord = asRecord(parsed); + if (getMessageEventId(directRecord)) { + return directRecord; + } + + const stdout = getToolString(directRecord, ["stdout"]); + if (!stdout) { + return null; + } + + const stdoutRecord = asRecord(parseToolResultValue(stdout)); + return getMessageEventId(stdoutRecord) ? stdoutRecord : null; +} + +function getMessageEventId(record: Record) { + return getToolString(record, [ + "event_id", + "eventId", + "message_id", + "messageId", + ]); +} diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index 3ae4051f0..9515be309 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -1,32 +1,88 @@ import * as React from "react"; -import { Bot, Brain, ChevronDown, Radio, TerminalSquare } from "lucide-react"; +import { CheckCheck, ChevronDown, Radio } from "lucide-react"; -import { - resolveUserLabel, - type UserProfileLookup, -} from "@/features/profile/lib/identity"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { cn } from "@/shared/lib/cn"; -import { Markdown } from "@/shared/ui/markdown"; -import { UserAvatar } from "@/shared/ui/UserAvatar"; -import type { TranscriptItem } from "./agentSessionTypes"; -import { ToolItem } from "./AgentSessionToolItem"; -import { formatTranscriptTime } from "./agentSessionUtils"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Toggle } from "@/shared/ui/toggle"; +import type { PromptSection, TranscriptItem } from "./agentSessionTypes"; +import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem"; +import { + ActivityRow, + ActivityRowContent, + ActivityRowLabel, + type ActivityRowStats, + splitActivityRowLabel, +} from "./activityRenderClasses/ActivityRow"; +import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp"; +import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types"; +import type { FileEditDiff } from "./agentSessionFileEditDiff"; +import { + buildTranscriptDisplayBlocks, + formatTurnSetupLabel, + turnSetupDetail, + turnSetupTimestamp, + type TranscriptDisplayBlock, + type TranscriptTurnSegment, +} from "./agentSessionTranscriptGrouping"; +import { buildCompactToolSummary } from "./agentSessionToolSummary"; +import { formatTranscriptTimestampTitle } from "./agentSessionUtils"; +import { hasFileEditLineDiff } from "./FileEditDiffView"; +import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble"; + +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; + } +} export function AgentSessionTranscriptList({ + agentAvatarUrl, agentName, + agentPubkey, emptyDescription, items, profiles, -}: { - agentName: string; +}: AgentTranscriptIdentityProps & { emptyDescription: string; items: TranscriptItem[]; profiles?: UserProfileLookup; }) { + const displayBlocks = React.useMemo( + () => buildTranscriptDisplayBlocks(items), + [items], + ); + if (items.length === 0) { return ( -
+

No ACP activity yet

{emptyDescription}

@@ -34,225 +90,599 @@ export function AgentSessionTranscriptList({ ); } + return ( +
+
+ {displayBlocks.map((block) => ( +
+ +
+ ))} +
+
+ ); +} + +function TranscriptAcpSourceBadge({ source }: { source: string }) { + return ( + + {source} + + ); +} + +function getDisplayBlockKey(block: TranscriptDisplayBlock) { + if (block.kind === "single") { + return block.item.id; + } + return `turn:${block.turnId}`; +} + +function TranscriptDisplayBlockView({ + agentAvatarUrl, + agentName, + agentPubkey, + block, + profiles, +}: AgentTranscriptIdentityProps & { + block: TranscriptDisplayBlock; + profiles?: UserProfileLookup; +}) { + if (block.kind === "single") { + return ( + + ); + } + return (
- {items.map((item) => ( -
- -
+ {block.segments.map((segment) => ( + ))}
); } -const TranscriptItemView = React.memo(function TranscriptItemView({ +function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) { + if (segment.kind === "setup") { + return `turn:${turnId}:setup`; + } + if (segment.kind === "prompt") { + return `turn:${turnId}:prompt`; + } + if (segment.kind === "summary") { + return segment.summary.id; + } + return segment.item.id; +} + +function TranscriptTurnSegmentView({ + agentAvatarUrl, agentName, - item, + agentPubkey, profiles, -}: { - agentName: string; - item: TranscriptItem; + segment, +}: AgentTranscriptIdentityProps & { profiles?: UserProfileLookup; + segment: TranscriptTurnSegment; }) { - if (item.type === "message") { + if (segment.kind === "prompt") { return ( - + ); } - if (item.type === "tool") { - return ; - } - if (item.type === "thought") { - return ; + + if (segment.kind === "setup") { + return ; } - if (item.type === "metadata") { - return ; + + if (segment.kind === "summary") { + return ( + + ); } - return ; -}); -function MessageItem({ + return ( + + ); +} + +function SameKindSummaryItem({ + agentAvatarUrl, agentName, + agentPubkey, + profiles, + summary, +}: AgentTranscriptIdentityProps & { + profiles?: UserProfileLookup; + summary: Extract["summary"]; +}) { + const groupedFileEditDiffs = React.useMemo( + () => + summary.renderClass === "file-edit" + ? getGroupedFileEditDiffs(summary.items) + : [], + [summary.items, summary.renderClass], + ); + const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs); + const expandsToToolItems = summary.items.every( + (item) => item.type === "tool", + ); + + return ( + + + + {expandsToToolItems + ? summary.items.map((item) => ( + + )) + : summary.items.map((item) => ( +

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

+ ))} +
+
+ ); +} + +function getGroupedFileEditDiffs(items: TranscriptItem[]): FileEditDiff[] { + return items.flatMap((item) => { + if (item.type !== "tool" || item.isError) { + return []; + } + + const diff = buildCompactToolSummary(item).fileEditDiff; + return diff && hasFileEditLineDiff(diff) ? [diff] : []; + }); +} + +function summarizeFileEditDiffs( + diffs: FileEditDiff[], +): ActivityRowStats | null { + if (diffs.length === 0) { + return null; + } + + return diffs.reduce( + (stats, diff) => ({ + additions: stats.additions + diff.additions, + deletions: stats.deletions + diff.deletions, + }), + { additions: 0, deletions: 0 }, + ); +} + +function ToolRunSummaryLabel({ + label, + stats, +}: { + label: string; + stats?: ActivityRowStats | null; +}) { + const parts = splitActivityRowLabel(label); + + if (!parts) { + return {label}; + } + + return ( + + ); +} + +function TurnPromptBlock({ + context, + profiles, + setup, + user, +}: { + context: Extract | null; + profiles?: UserProfileLookup; + setup: Extract[]; + user: Extract; +}) { + return ( +
+ {SHOW_TRANSCRIPT_ACP_SOURCE ? ( +
+ + {context ? ( + + ) : null} +
+ ) : null} + +
+ ); +} + +function PromptUserMessage({ + context = null, item, profiles, + setup = [], }: { - agentName: string; + context?: Extract | null; item: Extract; profiles?: UserProfileLookup; + setup?: Extract[]; }) { - const isAssistant = item.role === "assistant"; - 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"; + const [contextOpen, setContextOpen] = React.useState(false); + return ( + <> + + } + item={item} + profiles={profiles} + /> + + + ); +} + +function PromptContextSections({ + className, + sections, +}: { + className?: string; + sections: PromptSection[]; +}) { return (
- {!isAssistant ? ( - ( + - ) : null} -
+ ); +} + +function PromptContextSectionAccordion({ + section, +}: { + section: PromptSection; +}) { + const [open, setOpen] = React.useState(false); + const body = section.body.trim(); + + return ( +
+ +
); } -function ThoughtItem({ - item, +function PromptContextDialog({ + context, + onOpenChange, + open, + setup, }: { - item: Extract; + context: Extract | null; + onOpenChange: (open: boolean) => void; + open: boolean; + setup: Extract[]; }) { + if (!open || !context || context.sections.length === 0) { + return null; + } + + const setupText = formatPromptSetupSummary(setup); + return ( -
- - - {item.title} - - - -
- -
-
+ + +
+ + Prompt context + {setupText ? ( +
+ + {setupText} +
+ ) : null} +
+ +
+ +
+
+
+
); } -function MetadataItem({ - item, +function formatPromptSetupSummary( + items: Extract[], +) { + const label = formatTurnSetupLabel(items); + const detail = turnSetupDetail(items); + return [label, detail].filter(Boolean).join(" · "); +} + +function TurnSetupFooter({ + context = null, + contextOpen = false, + items, + messageLink = null, + onContextOpenChange, + showTimestamp = true, + timestamp, }: { - item: Extract; + context?: Extract | null; + contextOpen?: boolean; + items: Extract[]; + messageLink?: { channelId: string; messageId: string } | null; + onContextOpenChange?: (open: boolean) => void; + showTimestamp?: boolean; + 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 showTimestamp ? ( + + ) : null; + } + + const contextToggle = showContext ? ( + + + ) : null; + return ( -
- - - {item.title} - - {item.sections.length} section{item.sections.length === 1 ? "" : "s"} +
+ {showContext && showSetup ? contextToggle : null} + {!showContext && showSetup ? ( + + + {tooltipText} - - -
-
- {item.sections.map((section) => ( -
- - {section.title} - - -
-              {section.body.trim() || "No metadata."}
-            
-
- ))} -
-
+ ) : null} + {showContext && !showSetup ? contextToggle : null} + {showTimestamp ? ( + + ) : null} +
); } -function LifecycleItem({ +function getTranscriptMessageLink( + item: Extract, +) { + if (!item.channelId || !item.messageId) return null; + return { + channelId: item.channelId, + messageId: item.messageId, + }; +} + +function TranscriptItemRow({ + agentAvatarUrl, + agentName, + agentPubkey, item, + profiles, +}: AgentTranscriptIdentityProps & { + item: TranscriptItem; + profiles?: UserProfileLookup; +}) { + return ( +
+ {SHOW_TRANSCRIPT_ACP_SOURCE && item.acpSource ? ( + + ) : null} + +
+ ); +} + +function TurnSetupStatus({ + items, }: { - item: Extract; + items: Extract[]; }) { - const isError = item.title.toLowerCase().includes("error"); + const timestamp = turnSetupTimestamp(items); + if (items.length === 0 || !timestamp) { + return null; + } + return (
- {item.title} - {item.text ? - {item.text} : null} - +
); } -const fullDateTimeFormat = new Intl.DateTimeFormat(undefined, { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", -}); - -function TranscriptTimestamp({ timestamp }: { timestamp: string }) { - const formatted = formatTranscriptTime(timestamp); - if (!formatted) return null; - const date = new Date(timestamp); - const fullDateTime = Number.isNaN(date.getTime()) - ? timestamp - : fullDateTimeFormat.format(date); +const TranscriptItemView = React.memo(function TranscriptItemView({ + agentAvatarUrl, + agentName, + agentPubkey, + item, + profiles, +}: AgentTranscriptIdentityProps & { + item: TranscriptItem; + profiles?: UserProfileLookup; +}) { return ( - - - - {formatted} - - - {fullDateTime} - + ); -} +}); diff --git a/desktop/src/features/agents/ui/FileEditDiffView.tsx b/desktop/src/features/agents/ui/FileEditDiffView.tsx new file mode 100644 index 000000000..6a250a5f3 --- /dev/null +++ b/desktop/src/features/agents/ui/FileEditDiffView.tsx @@ -0,0 +1,56 @@ +import { cn } from "@/shared/lib/cn"; +import type { + FileEditDiff, + FileEditDiffLine, +} from "./agentSessionFileEditDiff"; + +export function hasFileEditLineDiff(diff: FileEditDiff) { + return diff.lines.some( + (line) => line.kind === "add" || line.kind === "remove", + ); +} + +export function FileEditDiffBlock({ diff }: { diff: FileEditDiff }) { + return ( +
+
+        
+      
+
+ {diff.path} +
+
+ ); +} + +function FileEditDiffLines({ diff }: { diff: FileEditDiff }) { + return diff.lines + .filter((line) => line.kind !== "meta") + .map((line, index) => ( + + )); +} + +function FileEditDiffLineView({ line }: { line: FileEditDiffLine }) { + return ( + + {line.text || " "} + + ); +} diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index df4d7f474..ae4909cb4 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -21,17 +21,28 @@ import type { ObserverEvent, TranscriptItem, } from "./agentSessionTypes"; +import { + deriveLatestSessionId, + resolveDisplayEvents, + resolveRawRailLayout, + scopeByChannel, +} from "./agentSessionPanelLayout"; import { shorten } from "./agentSessionUtils"; import { useObserverEvents, useAgentTranscript } from "./useObserverEvents"; type ManagedAgentSessionPanelProps = { - agent: Pick; + agent: Pick & { + avatarUrl?: string | null; + }; channelId?: string | null; className?: string; emptyDescription?: string; + rawLayout?: "responsive" | "exclusive"; showHeader?: boolean; showRaw?: boolean; profiles?: UserProfileLookup; + rawEventsOverride?: ObserverEvent[]; + transcriptOverride?: TranscriptItem[]; }; export function ManagedAgentSessionPanel({ @@ -39,9 +50,12 @@ export function ManagedAgentSessionPanel({ channelId = null, className, emptyDescription = "Mention this agent in a channel to watch the next turn.", + rawLayout = "responsive", showHeader = true, showRaw = true, profiles, + rawEventsOverride, + transcriptOverride, }: ManagedAgentSessionPanelProps) { const hasObserver = isManagedAgentActive(agent); const { connectionState, errorMessage, events } = useObserverEvents( @@ -51,27 +65,25 @@ export function ManagedAgentSessionPanel({ const transcript = useAgentTranscript(hasObserver, agent.pubkey); const scopedTranscript = React.useMemo( - () => - channelId - ? transcript.filter((item) => item.channelId === channelId) - : transcript, + () => scopeByChannel(transcript, channelId), [channelId, transcript], ); + const displayTranscript = transcriptOverride ?? scopedTranscript; + const scopedEvents = React.useMemo( - () => - channelId - ? events.filter((event) => event.channelId === channelId) - : events, + () => scopeByChannel(events, channelId), [channelId, events], ); + const displayEvents = React.useMemo( + () => resolveDisplayEvents(scopedEvents, rawEventsOverride), + [rawEventsOverride, scopedEvents], + ); - 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(displayEvents), + [displayEvents], + ); return (
) : null}
); @@ -140,47 +156,76 @@ function SessionHeader({ } function SessionBody({ + agentAvatarUrl, agentName, + agentPubkey, connectionState, emptyDescription, errorMessage, events, hasObserver, + hasTranscriptOverride, profiles, + rawLayout, showRaw, transcript, }: { + agentAvatarUrl: string | null; agentName: string; + agentPubkey: string; connectionState: ConnectionState; emptyDescription: string; errorMessage: string | null; events: ObserverEvent[]; hasObserver: boolean; + hasTranscriptOverride: boolean; profiles?: UserProfileLookup; + rawLayout: "responsive" | "exclusive"; showRaw: boolean; transcript: TranscriptItem[]; }) { + const rawRail = resolveRawRailLayout(showRaw, rawLayout); + + if (rawRail.mode === "exclusive") { + return ( + <> + + + {errorMessage ? ( +

+ + {errorMessage} +

+ ) : null} + + ); + } + return ( <> - {!hasObserver ? ( + {!hasObserver && !hasTranscriptOverride ? ( - ) : connectionState === "connecting" && events.length === 0 ? ( + ) : connectionState === "connecting" && + events.length === 0 && + !hasTranscriptOverride ? ( ) : (
- {showRaw ? : null} + {rawRail.mode === "side" ? : null}
)} diff --git a/desktop/src/features/agents/ui/RawEventRail.tsx b/desktop/src/features/agents/ui/RawEventRail.tsx index 5aca8963b..153b77e77 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/agents/ui/activityRenderClasses/ActivityRow.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx new file mode 100644 index 000000000..11fbe7ae9 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx @@ -0,0 +1,187 @@ +import * as React from "react"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; + +export type ActivityRowLabelParts = { + verb: string; + object?: React.ReactNode; +}; + +export type ActivityRowStats = { + additions: number; + deletions: number; +}; + +export type ActivityRowToneScope = "none" | "tool" | "summary"; + +type ActivityRowProps = { + children: React.ReactNode; + className?: string; + openToneScope?: Exclude; + testId?: string; + title?: string; +}; + +type ActivityRowContentProps = { + children: React.ReactNode; + className?: string; +}; + +const ACTIVITY_ROW_CONTENT_MARKER = Symbol("ActivityRowContent"); + +type ActivityRowContentComponent = React.FC & { + marker: typeof ACTIVITY_ROW_CONTENT_MARKER; +}; + +export function ActivityRow({ + children, + className, + openToneScope = "tool", + testId, + title, +}: ActivityRowProps) { + const childArray = React.Children.toArray(children); + const summaryChildren = childArray.filter( + (child) => !isActivityRowContent(child), + ); + const contentChildren = childArray.filter(isActivityRowContent); + + if (contentChildren.length === 0) { + return ( +
+ {children} +
+ ); + } + + return ( +
+ + {summaryChildren} + + + {contentChildren.map((child, index) => ( +
+ {child.props.children} +
+ ))} +
+ ); +} + +export function ActivityRowLabel({ + className, + object, + openToneScope, + stats, + title, + verb, +}: ActivityRowLabelParts & { + className?: string; + openToneScope: ActivityRowToneScope; + stats?: ActivityRowStats | null; + title?: string; +}) { + return ( + + + {verb} + + {object ? ( + + {object} + + ) : null} + {stats ? : null} + + ); +} + +export const ActivityRowContent = (({ children }: ActivityRowContentProps) => ( + <>{children} +)) as ActivityRowContentComponent; +ActivityRowContent.marker = ACTIVITY_ROW_CONTENT_MARKER; + +function ActivityRowStatsView({ stats }: { stats: ActivityRowStats }) { + return ( + + +{stats.additions} + -{stats.deletions} + + ); +} + +function isActivityRowContent( + child: React.ReactNode, +): child is React.ReactElement< + ActivityRowContentProps, + ActivityRowContentComponent +> { + return ( + React.isValidElement(child) && + typeof child.type !== "string" && + "marker" in child.type && + child.type.marker === ACTIVITY_ROW_CONTENT_MARKER + ); +} + +export function splitActivityRowLabel( + label: string, +): ActivityRowLabelParts | null { + const match = label.match( + /^(Added|Archived|Captured|Checked|Compacted|Created|Deleted|Edited|Ran|Read|Removed|Searched|Sent|Unarchived|Updated|Viewed)\s+(.+)$/, + ); + return match ? { verb: match[1], object: match[2] } : null; +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx new file mode 100644 index 000000000..88cb3f055 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/LifecycleActivity.tsx @@ -0,0 +1,63 @@ +import { AlertCircle, ShieldCheck } from "lucide-react"; + +import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; +import { ActivityRow, ActivityRowLabel } from "./ActivityRow"; +import { ToolActivity } from "./ToolActivity"; +import type { ActivityRenderClassItemProps } from "./types"; + +export function LifecycleActivity(props: ActivityRenderClassItemProps) { + if (props.item.type === "tool") { + return ; + } + if (props.item.type !== "lifecycle") { + return null; + } + + const isError = + props.item.renderClass === "error" || + props.item.title.toLowerCase().includes("error"); + const isPermission = props.item.renderClass === "permission"; + const timestampTitle = formatTranscriptTimestampTitle(props.item.timestamp); + + if (isPermission) { + return ( +
+ + {props.item.title} + {props.item.text ? ( + · {props.item.text} + ) : null} +
+ ); + } + + if (isError) { + return ( +
+ + {props.item.title} + {props.item.text ? ( + · {props.item.text} + ) : null} +
+ ); + } + + return ( + + + + ); +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx new file mode 100644 index 000000000..8428d73b7 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/MessageActivity.tsx @@ -0,0 +1,112 @@ +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Markdown } from "@/shared/ui/markdown"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import type { TranscriptItem } from "../agentSessionTypes"; +import { ToolActivity } from "./ToolActivity"; +import { TranscriptTimestamp } from "./TranscriptTimestamp"; +import type { + ActivityRenderClassItemProps, + AgentTranscriptIdentityProps, +} from "./types"; +import { UserMessageBubble } from "./UserMessageBubble"; + +export function MessageActivity(props: ActivityRenderClassItemProps) { + if (props.item.type === "tool") { + return ; + } + if (props.item.type !== "message") { + return null; + } + + return ( + + ); +} + +function MessageItem({ + agentAvatarUrl, + agentName, + agentPubkey, + item, + profiles, +}: AgentTranscriptIdentityProps & { + item: Extract; + profiles?: UserProfileLookup; +}) { + const isAssistant = item.role === "assistant"; + const text = item.text.trim(); + const messageLink = getTranscriptMessageLink(item); + const agentProfile = profiles?.[normalizePubkey(agentPubkey)] ?? null; + const assistantLabel = resolveUserLabel({ + pubkey: agentPubkey, + fallbackName: agentName, + profiles, + preferResolvedSelfLabel: true, + }); + const assistantAvatarUrl = agentProfile?.avatarUrl ?? agentAvatarUrl; + + if (!isAssistant) { + return ( + + } + item={item} + profiles={profiles} + /> + ); + } + + return ( +
+
+
+ + + {assistantLabel} + + +
+
+ +
+
+
+ ); +} + +function getTranscriptMessageLink( + item: Extract, +) { + if (!item.channelId || !item.messageId) return null; + return { + channelId: item.channelId, + messageId: item.messageId, + }; +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx new file mode 100644 index 000000000..3f7283312 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx @@ -0,0 +1,62 @@ +import { Markdown } from "@/shared/ui/markdown"; +import { + ActivityRow, + ActivityRowContent, + ActivityRowLabel, +} from "./ActivityRow"; +import { ToolActivity } from "./ToolActivity"; +import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; +import type { ActivityRenderClassItemProps } from "./types"; + +export function PlanActivity(props: ActivityRenderClassItemProps) { + if (props.item.type === "tool") { + return ; + } + if (props.item.type !== "plan") { + return null; + } + + if (props.item.isUpdate) { + return ( + + } + openToneScope="none" + verb="Updated" + /> + + ); + } + + return ( + + + + + + + ); +} + +function PlanUpdateLabelObject({ text }: { text: string }) { + return ( + <> + plan + {text ? ( + <> + {" · "} + {text} + + ) : null} + + ); +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.tsx new file mode 100644 index 000000000..f7f34880e --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/RawRailActivity.tsx @@ -0,0 +1,50 @@ +import { ChevronDown } from "lucide-react"; + +import { + ActivityRow, + ActivityRowContent, + ActivityRowLabel, +} from "./ActivityRow"; +import { ToolActivity } from "./ToolActivity"; +import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; +import type { ActivityRenderClassItemProps } from "./types"; + +export function RawRailActivity(props: ActivityRenderClassItemProps) { + if (props.item.type === "tool") { + return ; + } + if (props.item.type !== "metadata") { + return null; + } + + return ( + + + + {props.item.sections.map((section) => ( +
+ + {section.title} + + +
+              {section.body.trim() || "No metadata."}
+            
+
+ ))} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/SuppressedActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/SuppressedActivity.tsx new file mode 100644 index 000000000..099a7a40d --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/SuppressedActivity.tsx @@ -0,0 +1,39 @@ +import { + formatTranscriptTimestampTitle, + getToolDurationDisplay, +} from "../agentSessionUtils"; +import { + ActivityRow, + ActivityRowLabel, + splitActivityRowLabel, +} from "./ActivityRow"; +import type { ActivityRenderClassItemProps } from "./types"; + +export function SuppressedActivity(props: ActivityRenderClassItemProps) { + if (props.item.type !== "tool") { + return null; + } + + const action = props.item.descriptor.action; + const labelParts = + action ?? splitActivityRowLabel(props.item.descriptor.label); + const duration = getToolDurationDisplay(props.item); + + return ( + + + {duration ? ( + + {duration} + + ) : null} + + ); +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx new file mode 100644 index 000000000..fab78de10 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx @@ -0,0 +1,30 @@ +import { Markdown } from "@/shared/ui/markdown"; +import { + ActivityRow, + ActivityRowContent, + ActivityRowLabel, +} from "./ActivityRow"; +import { ToolActivity } from "./ToolActivity"; +import { formatTranscriptTimestampTitle } from "../agentSessionUtils"; +import type { ActivityRenderClassItemProps } from "./types"; + +export function ThoughtActivity(props: ActivityRenderClassItemProps) { + if (props.item.type === "tool") { + return ; + } + if (props.item.type !== "thought") { + return null; + } + + return ( + + + + + + + ); +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ToolActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ToolActivity.tsx new file mode 100644 index 000000000..3ccb5c24d --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/ToolActivity.tsx @@ -0,0 +1,11 @@ +import { ToolItem } from "../AgentSessionToolItem"; +import type { ActivityRenderClassItemProps } from "./types"; + +export function ToolActivity(props: ActivityRenderClassItemProps) { + const { item } = props; + if (item.type !== "tool") { + return null; + } + + return ; +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptActivityItem.tsx b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptActivityItem.tsx new file mode 100644 index 000000000..0fe9f98bf --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptActivityItem.tsx @@ -0,0 +1,34 @@ +import type { AgentActivityRenderClass } from "../agentSessionTypes"; +import { LifecycleActivity } from "./LifecycleActivity"; +import { MessageActivity } from "./MessageActivity"; +import { PlanActivity } from "./PlanActivity"; +import { RawRailActivity } from "./RawRailActivity"; +import { SuppressedActivity } from "./SuppressedActivity"; +import { ThoughtActivity } from "./ThoughtActivity"; +import { ToolActivity } from "./ToolActivity"; +import type { + ActivityRenderClassItemProps, + ActivityRenderClassPresenter, +} from "./types"; + +// Exhaustive render-class routing. Several semantic classes intentionally share +// a presenter when their row treatment is the same. +export const ACTIVITY_RENDER_CLASS_PRESENTERS = { + message: MessageActivity, + "relay-op": ToolActivity, + "file-edit": ToolActivity, + shell: ToolActivity, + status: LifecycleActivity, + thought: ThoughtActivity, + plan: PlanActivity, + permission: LifecycleActivity, + error: LifecycleActivity, + generic: ToolActivity, + "raw-rail": RawRailActivity, + suppressed: SuppressedActivity, +} satisfies Record; + +export function TranscriptActivityItem(props: ActivityRenderClassItemProps) { + const Presenter = ACTIVITY_RENDER_CLASS_PRESENTERS[props.item.renderClass]; + return ; +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx new file mode 100644 index 000000000..b544a391f --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { buildMessageLink } from "@/features/messages/lib/messageLink"; +import { cn } from "@/shared/lib/cn"; +import { + formatTranscriptTime, + formatTranscriptTimestampTitle, +} from "../agentSessionUtils"; + +export type TranscriptTimestampMessageLink = { + channelId: string; + messageId: string; +}; + +export function TranscriptTimestamp({ + className, + messageLink = null, + timestamp, +}: { + className?: string; + messageLink?: TranscriptTimestampMessageLink | null; + timestamp: string; +}) { + const formatted = formatTranscriptTime(timestamp); + const { goChannel } = useAppNavigation(); + const href = messageLink ? buildMessageLink(messageLink) : null; + const openMessage = React.useCallback( + (event: React.MouseEvent) => { + if (!messageLink) return; + event.preventDefault(); + event.stopPropagation(); + void goChannel(messageLink.channelId, { + messageId: messageLink.messageId, + }); + }, + [goChannel, messageLink], + ); + + if (!formatted) return null; + const fullDateTime = formatTranscriptTimestampTitle(timestamp); + + if (href) { + return ( + + {formatted} + + ); + } + + return ( + + {formatted} + + ); +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx new file mode 100644 index 000000000..a49cec218 --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -0,0 +1,70 @@ +import type * as React from "react"; + +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; +import { Markdown } from "@/shared/ui/markdown"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import type { TranscriptItem } from "../agentSessionTypes"; + +export function UserMessageBubble({ + bubbleClassName, + children, + className, + footer, + item, + profiles, +}: { + bubbleClassName?: string; + children?: React.ReactNode; + className?: string; + footer?: React.ReactNode; + item: Extract; + profiles?: UserProfileLookup; +}) { + 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 ( +
+ +
+
+ + {children} +
+ {footer} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/activityRenderClasses/types.ts b/desktop/src/features/agents/ui/activityRenderClasses/types.ts new file mode 100644 index 000000000..ad8aef0cb --- /dev/null +++ b/desktop/src/features/agents/ui/activityRenderClasses/types.ts @@ -0,0 +1,18 @@ +import type * as React from "react"; + +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { TranscriptItem } from "../agentSessionTypes"; + +export type AgentTranscriptIdentityProps = { + agentAvatarUrl: string | null; + agentName: string; + agentPubkey: string; +}; + +export type ActivityRenderClassItemProps = AgentTranscriptIdentityProps & { + item: TranscriptItem; + profiles?: UserProfileLookup; +}; + +export type ActivityRenderClassPresenter = + React.ComponentType; diff --git a/desktop/src/features/agents/ui/agentSessionFileEditDiff.ts b/desktop/src/features/agents/ui/agentSessionFileEditDiff.ts new file mode 100644 index 000000000..a7624ebbc --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionFileEditDiff.ts @@ -0,0 +1,203 @@ +import type { + AgentActivityDescriptor, + TranscriptItem, +} from "./agentSessionTypes"; +import { + asRecord, + getToolString, + parseToolResultValue, +} from "./agentSessionUtils"; + +type ToolItem = Extract; + +export type FileEditDiffLineKind = "add" | "remove" | "context" | "meta"; + +export type FileEditDiffLine = { + kind: FileEditDiffLineKind; + text: string; +}; + +export type FileEditDiffSummary = { + path: string; + filename: string; + additions: number; + deletions: number; +}; + +export type FileEditDiff = FileEditDiffSummary & { + lines: FileEditDiffLine[]; +}; + +const SHIKI_ADD_RE = /\s*\/\/\s*\[!code\s*\+\+\]\s*$/; +const SHIKI_REMOVE_RE = /\s*\/\/\s*\[!code\s*--\]\s*$/; + +export function buildFileEditDiff( + item: ToolItem, + descriptor: AgentActivityDescriptor, +): FileEditDiff | null { + if (descriptor.renderClass !== "file-edit") { + return null; + } + + const resultText = getResultText(item.result); + const path = + getToolString(item.args, ["path", "file", "file_path", "target_file"]) ?? + descriptor.object ?? + descriptor.preview ?? + getDiffPath(resultText); + + if (!path) { + return null; + } + + const lines = getDiffLines(resultText); + const stats = getDiffStats(resultText, lines); + if (!stats) { + return null; + } + + return { + path, + filename: basename(path), + additions: stats.additions, + deletions: stats.deletions, + lines, + }; +} + +function getResultText(result: string): string { + const parsed = parseToolResultValue(result); + if (typeof parsed === "string") { + return parsed; + } + + const record = asRecord(parsed); + const output = [ + getToolString(record, ["stdout", "output", "text"]), + getToolString(record, ["stderr"]), + ] + .filter((value): value is string => value != null) + .join("\n"); + + return output || result; +} + +function getDiffPath(text: string): string | null { + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^\+\+\+\s+(?:b\/)?(.+)$/); + if (match?.[1] && match[1] !== "/dev/null") { + return match[1].trim(); + } + } + + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^---\s+(?:a\/)?(.+)$/); + if (match?.[1] && match[1] !== "/dev/null") { + return match[1].trim(); + } + } + + return null; +} + +function getDiffLines(text: string): FileEditDiffLine[] { + const rawLines = text.split(/\r?\n/); + const hasUnifiedDiff = rawLines.some((line) => + /^(diff --git|--- |\+\+\+ |@@)/.test(line), + ); + const lines: FileEditDiffLine[] = []; + let inUnifiedDiff = false; + + for (const rawLine of rawLines) { + const shikiKind = getShikiDiffKind(rawLine); + if (shikiKind) { + lines.push({ + kind: shikiKind, + text: rawLine.replace( + shikiKind === "add" ? SHIKI_ADD_RE : SHIKI_REMOVE_RE, + "", + ), + }); + continue; + } + + if (hasUnifiedDiff && !inUnifiedDiff) { + if (!/^(diff --git|--- |\+\+\+ |@@)/.test(rawLine)) { + continue; + } + inUnifiedDiff = true; + } + + if (hasUnifiedDiff) { + lines.push(classifyUnifiedDiffLine(rawLine)); + continue; + } + + lines.push({ kind: "context", text: rawLine }); + } + + return trimTrailingEmptyContextLines( + lines.filter((line) => line.text.length > 0 || line.kind !== "meta"), + ); +} + +function trimTrailingEmptyContextLines( + lines: FileEditDiffLine[], +): FileEditDiffLine[] { + let end = lines.length; + while (end > 0) { + const line = lines[end - 1]; + if (line.kind !== "context" || line.text.length > 0) { + break; + } + end -= 1; + } + return end === lines.length ? lines : lines.slice(0, end); +} + +function getShikiDiffKind(line: string): "add" | "remove" | null { + if (SHIKI_ADD_RE.test(line)) return "add"; + if (SHIKI_REMOVE_RE.test(line)) return "remove"; + return null; +} + +function classifyUnifiedDiffLine(line: string): FileEditDiffLine { + if (/^(diff --git|--- |\+\+\+ |@@)/.test(line)) { + return { kind: "meta", text: line }; + } + if (line.startsWith("+")) { + return { kind: "add", text: line }; + } + if (line.startsWith("-")) { + return { kind: "remove", text: line }; + } + return { kind: "context", text: line }; +} + +function getDiffStats( + text: string, + lines: FileEditDiffLine[], +): Pick | null { + const additions = lines.filter((line) => line.kind === "add").length; + const deletions = lines.filter((line) => line.kind === "remove").length; + + if (additions > 0 || deletions > 0) { + return { additions, deletions }; + } + + const statAdditions = text.match(/(\d+)\s+insertions?\(\+\)/); + const statDeletions = text.match(/(\d+)\s+deletions?\(-\)/); + if (statAdditions || statDeletions) { + return { + additions: statAdditions ? Number(statAdditions[1]) : 0, + deletions: statDeletions ? Number(statDeletions[1]) : 0, + }; + } + + return null; +} + +function basename(path: string) { + const parts = path.replace(/\\/g, "/").split("/"); + return parts[parts.length - 1] || path; +} 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..3d7d3e88a --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveLatestSessionId, + resolveDisplayEvents, + 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); +}); + +// ---- resolveDisplayEvents ---- + +test("resolveDisplayEvents returns raw override events unchanged", () => { + const scopedEvents = [{ seq: 1, channelId: "channel-1" }]; + const rawEventsOverride = [{ seq: 2, channelId: "debug-channel" }]; + assert.equal( + resolveDisplayEvents(scopedEvents, rawEventsOverride), + rawEventsOverride, + ); +}); + +test("resolveDisplayEvents falls back to scoped live events", () => { + const scopedEvents = [{ seq: 1, channelId: "channel-1" }]; + assert.equal(resolveDisplayEvents(scopedEvents, undefined), scopedEvents); +}); + +// ---- 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..34b6f32cf --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts @@ -0,0 +1,55 @@ +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 function resolveDisplayEvents( + scopedEvents: ObserverEvent[], + rawEventsOverride: ObserverEvent[] | undefined, +): ObserverEvent[] { + return rawEventsOverride ?? scopedEvents; +} + +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/agentSessionToolClassifier.test.mjs b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs new file mode 100644 index 000000000..a32c14a76 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyTool, + extractSimpleEchoPipeContent, + parseBuzzCliCommand, + tokenizeShellCommand, +} from "./agentSessionToolClassifier.ts"; + +test("tokenizeShellCommand preserves quoted strings and command separators", () => { + assert.deepEqual( + tokenizeShellCommand( + 'echo "hello world" | buzz messages send --content - --channel agents; buzz feed get', + ), + [ + "echo", + "hello world", + "|", + "buzz", + "messages", + "send", + "--content", + "-", + "--channel", + "agents", + ";", + "buzz", + "feed", + "get", + ], + ); +}); + +test("extractSimpleEchoPipeContent reads the simple echo before a buzz pipe", () => { + const tokens = tokenizeShellCommand( + 'echo -n "Done. Eat my shorts." | buzz messages send --content - --channel agents', + ); + assert.equal( + extractSimpleEchoPipeContent(tokens, tokens.indexOf("buzz")), + "Done. Eat my shorts.", + ); +}); + +test("parseBuzzCliCommand promotes buzz message sends to message descriptors", () => { + const descriptor = parseBuzzCliCommand( + 'echo "Permission wired" | buzz messages send --channel agents --content -', + ); + + assert.equal(descriptor?.renderClass, "message"); + assert.equal(descriptor?.label, "Send Message"); + assert.equal(descriptor?.preview, "Permission wired"); + assert.equal(descriptor?.operation, "messages.send"); +}); + +test("classifyTool promotes buzz CLI shell commands to relay operations", () => { + const descriptor = classifyTool({ + title: "Shell", + toolName: "dev__shell", + buzzToolName: null, + args: { command: "buzz channels get --channel buzz-agent-observability" }, + result: "{}", + isError: false, + }); + + assert.equal(descriptor.renderClass, "relay-op"); + assert.equal(descriptor.label, "Channels Get"); + assert.equal(descriptor.preview, "buzz-agent-observability"); + assert.equal(descriptor.groupKey, "buzz-cli:channels.get"); +}); + +test("classifyTool falls back once to a generic descriptor", () => { + const descriptor = classifyTool({ + title: "Mystery", + toolName: "mcp__mystery", + buzzToolName: null, + args: { path: "notes.md" }, + result: "", + isError: false, + }); + + assert.equal(descriptor.renderClass, "generic"); + assert.equal(descriptor.label, "Ran tool"); + assert.equal(descriptor.preview, "notes.md"); + assert.equal(descriptor.source, "fallback"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolClassifier.ts b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts new file mode 100644 index 000000000..d5acfec2c --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolClassifier.ts @@ -0,0 +1,639 @@ +import type { + AgentActivityAction, + AgentActivityDescriptor, + AgentActivityRenderClass, + AgentActivityTone, + TranscriptItem, +} from "./agentSessionTypes"; +import { + formatToolTitle, + getBuzzToolInfo, + normalizeToolNameText, +} from "./agentSessionToolCatalog"; +import { + asRecord, + getToolString, + getToolStringList, +} from "./agentSessionUtils"; + +type ToolItem = Extract; + +export type ToolClassificationInput = { + title: string; + toolName: string; + buzzToolName: string | null; + args: Record; + result: string; + isError: boolean; +}; + +type ToolClassifierProvider = ( + input: ToolClassificationInput, +) => AgentActivityDescriptor | null; + +const DEVELOPER_TOOL_BASES = new Set([ + "shell", + "read_file", + "view_image", + "str_replace", + "todo", + "stop", + "postcompact", +]); + +const BUZZ_CLI_GROUPS = new Set([ + "messages", + "channels", + "dms", + "reactions", + "canvas", + "feed", + "users", + "workflows", + "social", + "repos", + "upload", + "mem", + "notes", + "patches", + "pr", + "issues", + "emoji", + "pack", +]); + +const BUZZ_CLI_ADMIN_VERBS = new Set([ + "archive", + "unarchive", + "create", + "delete", + "remove", + "add-channel-member", + "remove-channel-member", + "set-channel-add-policy", +]); + +const BUZZ_CLI_READ_VERBS = new Set([ + "get", + "list", + "thread", + "search", + "members", + "runs", + "notes", +]); + +const TOOL_CLASS_LABELS: Record = { + message: "Message", + "relay-op": "Buzz relay op", + "file-edit": "File edit", + shell: "Shell command", + status: "Status", + thought: "Thought", + plan: "Plan", + permission: "Permission", + error: "Error", + generic: "Tool", + "raw-rail": "Raw event", + suppressed: "Suppressed", +}; + +const providers: ToolClassifierProvider[] = [ + classifyDeveloperHarnessTool, + classifyBuzzTool, +]; + +export function classifyTool( + input: ToolClassificationInput, +): AgentActivityDescriptor { + for (const provider of providers) { + const descriptor = provider(input); + if (descriptor) { + return input.isError || descriptor.renderClass === "error" + ? { + ...descriptor, + renderClass: "error", + label: descriptor.label.endsWith("failed") + ? descriptor.label + : `${descriptor.label} failed`, + } + : descriptor; + } + } + + return genericDescriptor(input); +} + +export function classifyToolItem(item: ToolItem): AgentActivityDescriptor { + return classifyTool({ + title: item.title, + toolName: item.toolName, + buzzToolName: item.buzzToolName, + args: item.args, + result: item.result, + isError: item.isError, + }); +} + +export function renderClassLabel(renderClass: AgentActivityRenderClass) { + return TOOL_CLASS_LABELS[renderClass]; +} + +function classifyDeveloperHarnessTool( + input: ToolClassificationInput, +): AgentActivityDescriptor | null { + const kind = resolveDeveloperToolKind(input); + if (!kind) return null; + + if (kind === "shell") { + const command = getToolString(input.args, ["command"]); + const buzzCli = command ? parseBuzzCliCommand(command) : null; + if (buzzCli) { + return buzzCli; + } + return { + renderClass: "shell", + label: "Ran command", + preview: command, + action: { verb: "Ran", object: command ?? "command" }, + source: "harness", + groupKey: "shell:command", + }; + } + + if (kind === "read_file") { + const path = getToolString(input.args, ["path"]); + return { + renderClass: "generic", + label: "Read file", + preview: path, + action: { verb: "Read", object: path ?? "file" }, + source: "harness", + groupKey: "read_file", + }; + } + + if (kind === "view_image") { + const source = getToolString(input.args, ["source"]); + return { + renderClass: "generic", + label: "Viewed image", + preview: source ? basenameOrUrl(source) : null, + action: { + verb: "Viewed", + object: source ? basenameOrUrl(source) : "image", + }, + source: "harness", + groupKey: "view_image", + }; + } + + if (kind === "str_replace") { + const path = getToolString(input.args, ["path"]); + return { + renderClass: "file-edit", + label: "Edited file", + preview: path, + action: { verb: "Edited", object: path ?? "file" }, + source: "harness", + groupKey: "file-edit:str_replace", + }; + } + + if (kind === "todo") { + const preview = getTodoPreview(input.args); + return { + renderClass: "plan", + label: "Updated todos", + preview, + action: { verb: "Updated", object: preview }, + source: "harness", + groupKey: "plan:todo", + }; + } + + if (kind === "stop_hook") { + return { + renderClass: "suppressed", + label: "Checked todos", + preview: null, + action: { verb: "Checked", object: "todos" }, + source: "harness", + groupKey: "suppressed:stop-hook", + }; + } + + if (kind === "post_compact_hook") { + return { + renderClass: "status", + label: "Context compacted", + preview: null, + action: { verb: "Compacted", object: "context" }, + source: "harness", + groupKey: "status:post-compact", + }; + } + + const preview = genericPreview(input); + return { + renderClass: "generic", + label: "Ran tool", + preview, + action: { verb: "Ran", object: preview ?? "tool" }, + source: "harness", + groupKey: "generic:dev-mcp", + }; +} + +function classifyBuzzTool( + input: ToolClassificationInput, +): AgentActivityDescriptor | null { + const name = [input.buzzToolName, input.toolName, input.title].find( + (value) => value && getBuzzToolInfo(value), + ); + if (!name) return null; + + const info = getBuzzToolInfo(name); + if (!info) return null; + + const operation = normalizeToolNameText(name); + const label = formatToolTitle(name, input.title); + const preview = extractBuzzToolPreview(input.args); + return { + renderClass: isBuzzMessageSend(operation) ? "message" : "relay-op", + label, + preview, + action: actionForBuzzOperation(operation, preview, info.tone), + tone: info.tone, + operation, + object: preview, + source: "mcp", + groupKey: `buzz:${operation}`, + }; +} + +function genericDescriptor( + input: ToolClassificationInput, +): AgentActivityDescriptor { + const preview = genericPreview(input); + return { + renderClass: "generic", + label: "Ran tool", + preview, + action: { verb: "Ran", object: preview ?? "tool" }, + source: "fallback", + groupKey: `generic:${normalizeToolNameText(input.toolName || input.title)}`, + }; +} + +function resolveDeveloperToolKind( + input: ToolClassificationInput, +): + | "shell" + | "read_file" + | "view_image" + | "str_replace" + | "todo" + | "stop_hook" + | "post_compact_hook" + | "dev_mcp" + | null { + for (const value of [input.toolName, input.title, input.buzzToolName]) { + const kind = classifyDeveloperToolName(value); + if (kind) return kind; + } + return null; +} + +function classifyDeveloperToolName(value: string | null | undefined) { + if (!value) return null; + + const normalized = normalizeToolNameText(value); + const base = normalized.replace(/^buzz_dev_mcp_/, ""); + + if (base === "shell" || normalized.endsWith("_shell")) return "shell"; + if (base === "read_file" || normalized.endsWith("_read_file")) + return "read_file"; + if (base === "view_image" || normalized.endsWith("_view_image")) + return "view_image"; + if (base === "str_replace" || normalized.endsWith("_str_replace")) + return "str_replace"; + if (base === "todo") return "todo"; + if (base === "stop") return "stop_hook"; + if (base === "postcompact") return "post_compact_hook"; + if (DEVELOPER_TOOL_BASES.has(base) || normalized.includes("buzz_dev_mcp")) { + return "dev_mcp"; + } + return null; +} + +export function parseBuzzCliCommand( + command: string, +): AgentActivityDescriptor | null { + const tokens = tokenizeShellCommand(command); + const range = findBuzzCommand(tokens); + if (!range) return null; + + const group = tokens[range.groupIndex]; + const verb = tokens[range.verbIndex] ?? "run"; + const operation = `${group}.${verb}`; + const content = + group === "messages" && verb === "send" + ? extractBuzzCliSendMessageContent(tokens, range) + : null; + const preview = content ?? extractBuzzCliObjectPreview(tokens, range); + const tone = buzzCliTone(group, verb); + return { + renderClass: + group === "messages" && verb === "send" ? "message" : "relay-op", + label: titleForBuzzCli(group, verb), + preview, + action: actionForBuzzOperation(operation, preview, tone), + tone, + operation, + object: preview, + source: "shell", + groupKey: `buzz-cli:${operation}`, + }; +} + +function titleForBuzzCli(group: string, verb: string) { + if (group === "messages" && verb === "send") return "Send Message"; + return [group, verb] + .map((part) => + part + .split(/[-_]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "), + ) + .filter(Boolean) + .join(" "); +} + +function actionForBuzzOperation( + operation: string, + object: string | null, + tone: AgentActivityTone, +): AgentActivityAction { + const verb = buzzOperationVerbToken(operation); + return { + verb: buzzOperationVerb(verb, tone), + object: object ?? buzzOperationObject(operation), + }; +} + +function buzzOperationVerbToken(operation: string) { + if (operation.includes(".")) { + return operation.split(".")[1] ?? "run"; + } + return operation.split("_")[0] ?? "run"; +} + +function buzzOperationVerb(verb: string, tone: AgentActivityTone) { + if (verb === "add") return "Added"; + if (verb === "archive") return "Archived"; + if (verb === "create") return "Created"; + if (verb === "delete") return "Deleted"; + if (verb === "get" || verb === "list" || verb === "members") return "Read"; + if (verb === "remove") return "Removed"; + if (verb === "runs") return "Read"; + if (verb === "search") return "Searched"; + if (verb === "send") return "Sent"; + if (verb === "thread") return "Read"; + if (verb === "unarchive") return "Unarchived"; + if (tone === "read") return "Read"; + return "Updated"; +} + +function buzzOperationObject(operation: string) { + if (isBuzzMessageSend(operation)) return "message"; + if (operation.includes(".")) { + const [group] = operation.split("."); + return group ? group.replace(/[-_]+/g, " ") : "Buzz"; + } + const object = operation.replace( + /^(add|approve|archive|create|delete|edit|get|hide|join|leave|list|open|publish|remove|search|send|set|trigger|unarchive|update|vote)_/, + "", + ); + return object ? object.replace(/[-_]+/g, " ") : "Buzz"; +} + +function buzzCliTone(group: string, verb: string): AgentActivityTone { + if (BUZZ_CLI_ADMIN_VERBS.has(verb)) return "admin"; + if (BUZZ_CLI_READ_VERBS.has(verb)) return "read"; + if (group === "feed" && verb === "get") return "read"; + return "write"; +} + +function extractBuzzCliSendMessageContent( + tokens: string[], + range: BuzzCommandRange, +): string | null { + const content = getFlagValue(tokens, range.verbIndex + 1, "--content"); + if (!content) return null; + if (content !== "-") return content; + return extractSimpleEchoPipeContent(tokens, range.buzzIndex) ?? null; +} + +function extractBuzzCliObjectPreview( + tokens: string[], + range: BuzzCommandRange, +): string | null { + const flagPreview = + getFlagValue(tokens, range.verbIndex + 1, "--channel") ?? + getFlagValue(tokens, range.verbIndex + 1, "--event") ?? + getFlagValue(tokens, range.verbIndex + 1, "--query") ?? + getFlagValue(tokens, range.verbIndex + 1, "--name") ?? + getFlagValue(tokens, range.verbIndex + 1, "--file"); + if (flagPreview) return flagPreview; + + const next = tokens[range.verbIndex + 1]; + return next && !isCommandSeparator(next) && !next.startsWith("-") + ? next + : null; +} + +type BuzzCommandRange = { + buzzIndex: number; + groupIndex: number; + verbIndex: number; +}; + +function findBuzzCommand(tokens: string[]): BuzzCommandRange | null { + for (let i = 0; i < tokens.length; i++) { + if (!isBuzzExecutable(tokens[i])) continue; + + for (let j = i + 1; j < tokens.length; j++) { + if (isCommandSeparator(tokens[j])) break; + if (tokens[j].startsWith("-")) { + if ( + !tokens[j].includes("=") && + tokens[j + 1]?.startsWith("-") === false + ) { + j += 1; + } + continue; + } + if (!BUZZ_CLI_GROUPS.has(tokens[j])) continue; + const verbIndex = j + 1; + if (!tokens[verbIndex] || isCommandSeparator(tokens[verbIndex])) { + return null; + } + return { buzzIndex: i, groupIndex: j, verbIndex }; + } + } + return null; +} + +export 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 isBuzzExecutable(token: string) { + return token === "buzz" || token.split(/[\\/]/).pop() === "buzz"; +} + +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; +} + +export 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 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 genericPreview(input: ToolClassificationInput): string | null { + return ( + getToolString(input.args, [ + "command", + "path", + "source", + "query", + "name", + "content", + "message", + ]) ?? (input.title ? input.title : null) + ); +} + +function isBuzzMessageSend(operation: string) { + return operation === "send_message" || operation === "messages_send"; +} + +function basenameOrUrl(source: string): string { + const trimmed = source.trim(); + if ( + trimmed.startsWith("data:image/") || + trimmed.startsWith("http://") || + trimmed.startsWith("https://") + ) { + return trimmed; + } + return trimmed.split(/[/\\]/).pop() ?? trimmed; +} + +function getTodoPreview(args: Record): string | null { + const todos = args.todos; + if (!Array.isArray(todos)) return "todo list"; + if (todos.length === 0) return "empty list"; + const first = todos[0]; + const firstText = + first && typeof first === "object" + ? getToolString(asRecord(first), ["text"]) + : null; + if (firstText) + return todos.length > 1 ? `${firstText} (+${todos.length - 1})` : firstText; + return `${todos.length} item${todos.length === 1 ? "" : "s"}`; +} 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..17d621da4 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + formatDurationMs, + formatTranscriptTime, + getToolDurationDisplay, + isInlineImageData, + parseShellToolOutput, + 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"); +}); + +test("formatTranscriptTime renders a short 12-hour time without seconds", () => { + assert.equal(formatTranscriptTime("2026-06-30T17:00:02.000"), "5:00 PM"); +}); + +// ---- 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); +}); + +test("parseShellToolOutput extracts stdout from a shell result envelope", () => { + assert.deepEqual( + parseShellToolOutput( + JSON.stringify({ + exit_code: 0, + stdout: "4 files changed\n", + stderr: "", + timed_out: false, + }), + ), + { + exitCode: 0, + raw: "", + stderr: "", + stdout: "4 files changed\n", + timedOut: false, + }, + ); +}); + +test("parseShellToolOutput preserves non-envelope output as raw text", () => { + assert.deepEqual(parseShellToolOutput(JSON.stringify("plain output")), { + exitCode: null, + raw: "plain output", + stderr: "", + stdout: "", + timedOut: false, + }); +}); + +// ---- 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.test.mjs b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs new file mode 100644 index 000000000..bdfec16a0 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.test.mjs @@ -0,0 +1,334 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildCompactToolSummary } from "./agentSessionToolSummary.ts"; + +const baseTimestamp = "2026-06-14T19:00:00.000Z"; + +function makeTool(overrides = {}) { + return { + id: "tool:1", + type: "tool", + title: "Tool call", + toolName: "shell", + buzzToolName: null, + status: "completed", + args: {}, + result: "", + isError: false, + timestamp: baseTimestamp, + startedAt: baseTimestamp, + completedAt: "2026-06-14T19:00:01.000Z", + ...overrides, + }; +} + +test("buildCompactToolSummary formats Buzz send_message preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "send_message", + buzzToolName: "send_message", + title: "Send Message", + args: { content: "Hello team" }, + }), + ); + + assert.equal(summary.kind, "message"); + 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, "message"); + 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", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "buzz-dev-mcp__shell", + args: { command: "git status" }, + }), + ); + + assert.equal(summary.label, "Ran command"); + assert.equal(summary.preview, "git status"); + assert.deepEqual(summary.action, { verb: "Ran", object: "git status" }); + assert.equal(summary.presentation, "inline"); +}); + +test("buildCompactToolSummary formats view_image thumbnail source", () => { + const source = + "https://sprout-oss.stage.blox.sqprod.co/media/ffd1b2721f2d52e19f0ca2be9aa7842cdec5b4e0215aaab2a67c26a2a76a6a83.png"; + const summary = buildCompactToolSummary( + makeTool({ + toolName: "buzz-dev-mcp__view_image", + args: { source }, + }), + ); + + assert.equal(summary.label, "Viewed image"); + assert.equal(summary.thumbnailSrc, source); + assert.equal(summary.preview, source); +}); + +test("buildCompactToolSummary uses basename for local view_image paths", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "view_image", + args: { source: "desktop/assets/screenshot.png" }, + }), + ); + + assert.equal(summary.thumbnailSrc, null); + assert.equal(summary.preview, "screenshot.png"); +}); + +test("buildCompactToolSummary formats read_file path preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "read_file", + args: { path: "desktop/src/app/App.tsx" }, + }), + ); + + assert.equal(summary.label, "Read file"); + assert.equal(summary.preview, "desktop/src/app/App.tsx"); + assert.deepEqual(summary.action, { + verb: "Read", + object: "desktop/src/app/App.tsx", + }); +}); + +test("buildCompactToolSummary formats todo list preview", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "todo", + args: { + todos: [ + { text: "Ship compact summaries", done: false }, + { text: "Verify UI", done: false }, + ], + }, + }), + ); + + assert.equal(summary.label, "Updated todos"); + assert.equal(summary.preview, "Ship compact summaries (+1)"); +}); + +test("buildCompactToolSummary uses running and failed labels", () => { + assert.equal( + buildCompactToolSummary( + makeTool({ toolName: "str_replace", status: "executing" }), + ).label, + "Editing file", + ); + assert.equal( + buildCompactToolSummary( + makeTool({ toolName: "str_replace", status: "failed", isError: true }), + ).label, + "Edit failed", + ); +}); + +test("buildCompactToolSummary promotes non-send buzz CLI commands to relay ops", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "shell", + args: { + command: "buzz channels get --channel channel-1", + }, + }), + ); + + assert.equal(summary.kind, "relay-op"); + assert.equal(summary.label, "Channels Get"); + assert.equal(summary.preview, "channel-1"); + assert.deepEqual(summary.action, { verb: "Read", object: "channel-1" }); + assert.equal(summary.presentation, "inline"); +}); + +test("buildCompactToolSummary derives structured actions for native Buzz MCP tools", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "get_channel", + buzzToolName: "get_channel", + args: { + channel_id: "channel-1", + }, + }), + ); + + assert.equal(summary.kind, "relay-op"); + assert.deepEqual(summary.action, { verb: "Read", object: "channel-1" }); +}); + +test("buildCompactToolSummary promotes file edits and todos to first-class classes", () => { + assert.equal( + buildCompactToolSummary( + makeTool({ toolName: "str_replace", args: { path: "src/app.ts" } }), + ).kind, + "file-edit", + ); + assert.equal( + buildCompactToolSummary(makeTool({ toolName: "todo", args: { todos: [] } })) + .kind, + "plan", + ); +}); + +test("buildCompactToolSummary formats file edits as filename plus diff stats", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "str_replace", + args: { path: "desktop/src/app/App.tsx" }, + result: [ + "Replaced 1 occurrence.", + "", + "--- a/desktop/src/app/App.tsx", + "+++ b/desktop/src/app/App.tsx", + "@@", + "-", + "+", + "+", + ].join("\n"), + }), + ); + + assert.equal(summary.kind, "file-edit"); + assert.equal(summary.preview, "App.tsx"); + assert.deepEqual(summary.fileEditSummary, { + path: "desktop/src/app/App.tsx", + filename: "App.tsx", + additions: 2, + deletions: 1, + }); + assert.deepEqual(summary.fileEditDiff?.lines, [ + { kind: "meta", text: "--- a/desktop/src/app/App.tsx" }, + { kind: "meta", text: "+++ b/desktop/src/app/App.tsx" }, + { kind: "meta", text: "@@" }, + { kind: "remove", text: "-" }, + { kind: "add", text: "+" }, + { kind: "add", text: "+" }, + ]); +}); + +test("buildCompactToolSummary counts Shiki diff markers for file edit stats", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "str_replace", + args: { path: "desktop/src/app/App.tsx" }, + result: [ + "const keep = true;", + "const next = true; // [!code ++]", + "const old = true; // [!code --]", + ].join("\n"), + }), + ); + + assert.deepEqual(summary.fileEditSummary, { + path: "desktop/src/app/App.tsx", + filename: "App.tsx", + additions: 1, + deletions: 1, + }); + assert.deepEqual(summary.fileEditDiff?.lines, [ + { kind: "context", text: "const keep = true;" }, + { kind: "add", text: "const next = true;" }, + { kind: "remove", text: "const old = true;" }, + ]); +}); + +test("buildCompactToolSummary parses file edit stats from shell JSON stdout", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "str_replace", + args: { path: "desktop/src/app/App.tsx" }, + result: JSON.stringify({ + stdout: [ + "diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx", + "--- a/desktop/src/app/App.tsx", + "+++ b/desktop/src/app/App.tsx", + "@@", + "-old", + "+new", + ].join("\n"), + }), + }), + ); + + assert.deepEqual(summary.fileEditSummary, { + path: "desktop/src/app/App.tsx", + filename: "App.tsx", + additions: 1, + deletions: 1, + }); + assert.deepEqual(summary.fileEditDiff?.lines, [ + { + kind: "meta", + text: "diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx", + }, + { kind: "meta", text: "--- a/desktop/src/app/App.tsx" }, + { kind: "meta", text: "+++ b/desktop/src/app/App.tsx" }, + { kind: "meta", text: "@@" }, + { kind: "remove", text: "-old" }, + { kind: "add", text: "+new" }, + ]); +}); + +test("buildCompactToolSummary trims only trailing blank diff lines", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "str_replace", + args: { path: "desktop/src/app/App.tsx" }, + result: [ + "--- a/desktop/src/app/App.tsx", + "+++ b/desktop/src/app/App.tsx", + "@@", + " const before = true;", + "", + "+const after = true;", + "", + ].join("\n"), + }), + ); + + assert.deepEqual(summary.fileEditDiff?.lines, [ + { kind: "meta", text: "--- a/desktop/src/app/App.tsx" }, + { kind: "meta", text: "+++ b/desktop/src/app/App.tsx" }, + { kind: "meta", text: "@@" }, + { kind: "context", text: " const before = true;" }, + { kind: "context", text: "" }, + { kind: "add", text: "+const after = true;" }, + ]); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts new file mode 100644 index 000000000..ddcd734dd --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -0,0 +1,112 @@ +import type { + AgentActivityAction, + ToolStatus, + TranscriptItem, +} from "./agentSessionTypes"; +import type { AgentActivityDescriptor } from "./agentSessionTypes"; +import { getToolString } from "./agentSessionUtils"; +import { classifyToolItem } from "./agentSessionToolClassifier"; +import { + buildFileEditDiff, + type FileEditDiff, + type FileEditDiffSummary, +} from "./agentSessionFileEditDiff"; + +export type CompactToolKind = + | "message" + | "relay-op" + | "file-edit" + | "shell" + | "status" + | "thought" + | "plan" + | "permission" + | "error" + | "generic" + | "raw-rail" + | "suppressed"; + +export type CompactToolSummary = { + action: AgentActivityAction | null; + kind: CompactToolKind; + label: string; + preview: string | null; + fileEditSummary: FileEditDiffSummary | null; + fileEditDiff: FileEditDiff | null; + /** When set, the compact row renders a tiny image instead of text preview. */ + thumbnailSrc: string | null; + presentation: "inline" | "message"; + descriptor: AgentActivityDescriptor; +}; + +type ToolItem = Extract; + +export type CompactFileEditSummary = FileEditDiffSummary; + +/** Build the muted compact summary label and preview for any tool row. */ +export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { + const descriptor = item.descriptor ?? classifyToolItem(item); + const fileEditDiff = buildFileEditDiff(item, descriptor); + const fileEditSummary = fileEditDiff + ? { + path: fileEditDiff.path, + filename: fileEditDiff.filename, + additions: fileEditDiff.additions, + deletions: fileEditDiff.deletions, + } + : null; + const thumbnailSrc = getThumbnailSrc(item, descriptor); + const failed = item.isError || item.status === "failed"; + const running = item.status === "executing" || item.status === "pending"; + return { + action: descriptor.action ?? null, + kind: descriptor.renderClass, + label: labelForStatus(descriptor, item.status, failed, running), + preview: fileEditSummary?.filename ?? descriptor.preview, + fileEditSummary, + fileEditDiff, + thumbnailSrc, + presentation: descriptor.renderClass === "message" ? "message" : "inline", + descriptor, + }; +} + +function labelForStatus( + descriptor: AgentActivityDescriptor, + status: ToolStatus, + failed: boolean, + running: boolean, +) { + const label = descriptor.label; + if (descriptor.groupKey === "file-edit:str_replace") { + if (failed) return "Edit failed"; + if (running) return "Editing file"; + return "Edited file"; + } + if (failed) { + return label.endsWith("failed") ? label : `${label} failed`; + } + if (running) return label; + if (status === "completed") return label; + return label; +} + +function getThumbnailSrc( + item: ToolItem, + descriptor: AgentActivityDescriptor, +): string | null { + const operation = + descriptor.operation ?? descriptor.groupKey ?? item.toolName; + if (!operation.includes("view_image") && item.toolName !== "view_image") { + return null; + } + + const source = getToolString(item.args, ["source"]); + if (!source) return null; + const trimmed = source.trim(); + return trimmed.startsWith("data:image/") || + trimmed.startsWith("http://") || + trimmed.startsWith("https://") + ? trimmed + : null; +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index d984b0c19..49b8104de 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -13,6 +13,7 @@ const baseEvent = { sessionId: "sess-1", turnId: "turn-1", }; +const PROMPT_EVENT_ID = "c".repeat(64); function acpToolUpdate(seq, update) { return { @@ -75,7 +76,7 @@ test("buildTranscript renders Prompt context + user message for a multi-block se { type: "text", text: "[Context]\nScope: thread" }, { type: "text", - text: `[Buzz event: @mention]\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`, + text: `[Buzz event: @mention]\nEvent ID: ${PROMPT_EVENT_ID.toUpperCase()}\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`, }, ], }, @@ -94,6 +95,40 @@ test("buildTranscript renders Prompt context + user message for a multi-block se ["Agent Memory — core", "Context", "Buzz event: @mention"], "every section header is counted", ); + const userMessage = items.find((i) => i.type === "message"); + assert.equal(userMessage.messageId, PROMPT_EVENT_ID); +}); + +test("buildTranscript falls back to a single turn trigger id for older prompt frames", () => { + const promptEvent = { + ...baseEvent, + seq: 2, + payload: { + method: "session/prompt", + params: { + sessionId: "sess-1", + prompt: [ + { + type: "text", + text: `[Buzz event: @mention]\nFrom: x (hex: ${"a".repeat(64)})\nContent: hello`, + }, + ], + }, + }, + }; + const [userMessage] = buildTranscript([ + { + ...baseEvent, + seq: 1, + kind: "turn_started", + payload: { + triggeringEventIds: [PROMPT_EVENT_ID], + }, + }, + promptEvent, + ]).filter((candidate) => candidate.type === "message"); + + assert.equal(userMessage.messageId, PROMPT_EVENT_ID); }); test("buildTranscript keeps read_file activity categorized by the actual tool when output names Buzz tools", () => { @@ -194,3 +229,413 @@ test("buildTranscript categorizes explicit Buzz tool calls for the activity bar" assert.deepEqual(item.args, { limit: 20 }); assert.equal(item.status, "completed"); }); + +function sessionUpdate(seq, update, overrides = {}) { + return { + ...baseEvent, + ...overrides, + seq, + kind: "acp_read", + payload: { + method: "session/update", + params: { + sessionId: overrides.sessionId ?? baseEvent.sessionId, + update, + }, + }, + }; +} + +function assistantChunk(seq, messageId, text, overrides = {}) { + return sessionUpdate( + seq, + { + sessionUpdate: "agent_message_chunk", + messageId, + content: { type: "text", text }, + }, + overrides, + ); +} + +test("buildTranscript preserves author pubkeys on user message chunks", () => { + const authorPubkey = "b".repeat(64); + const [item] = buildTranscript([ + sessionUpdate(25, { + sessionUpdate: "user_message_chunk", + messageId: "user-chunk-1", + authorPubkey, + content: { type: "text", text: "please keep this visible" }, + }), + ]).filter((candidate) => candidate.type === "message"); + + assert.equal(item.role, "user"); + assert.equal(item.authorPubkey, authorPubkey); + assert.equal(item.messageId, null); +}); + +test("buildTranscript preserves real event ids on user message chunks", () => { + const authorPubkey = "b".repeat(64); + const messageId = "d".repeat(64); + const [item] = buildTranscript([ + sessionUpdate(26, { + sessionUpdate: "user_message_chunk", + messageId, + authorPubkey, + content: { type: "text", text: "this came from a channel message" }, + }), + ]).filter((candidate) => candidate.type === "message"); + + assert.equal(item.role, "user"); + assert.equal(item.messageId, messageId); +}); + +test("buildTranscript de-duplicates repeated tool updates into one canonical row", () => { + const items = toolItems([ + acpToolUpdate(40, { + sessionUpdate: "tool_call", + toolCallId: "call-dupe", + status: "executing", + title: "shell", + kind: "shell", + rawInput: { command: "echo hi" }, + }), + acpToolUpdate(41, { + sessionUpdate: "tool_call_update", + toolCallId: "call-dupe", + status: "completed", + title: "shell", + kind: "shell", + rawOutput: "hi", + }), + acpToolUpdate(42, { + sessionUpdate: "tool_call_update", + toolCallId: "call-dupe", + status: "completed", + title: "shell", + kind: "shell", + rawOutput: "hi", + }), + ]); + + assert.equal(items.length, 1); + assert.equal(items[0].id, `tool:${baseEvent.channelId}:call-dupe`); + assert.equal(items[0].status, "completed"); + assert.equal(items[0].result, "hi"); +}); + +test("buildTranscript keeps a completed tool terminal when a late executing call arrives", () => { + const [item] = toolItems([ + acpToolUpdate(50, { + sessionUpdate: "tool_call_update", + toolCallId: "call-regression", + status: "completed", + title: "shell", + kind: "shell", + rawOutput: "done", + }), + acpToolUpdate(51, { + sessionUpdate: "tool_call", + toolCallId: "call-regression", + status: "executing", + title: "shell", + kind: "shell", + rawInput: { command: "echo done" }, + }), + ]); + + assert.equal(item.status, "completed"); + assert.equal(item.completedAt, baseEvent.timestamp); + assert.deepEqual(item.args, { command: "echo done" }); + assert.equal(item.result, "done"); +}); + +test("buildTranscript rebuilds out-of-order tool frames as one canonical row with retained ids", () => { + const [item] = toolItems([ + sessionUpdate( + 60, + { + sessionUpdate: "tool_call_update", + toolCallId: "call-out-of-order", + status: "completed", + title: "read_file", + kind: "read_file", + rawOutput: "file contents", + }, + { + channelId: "22222222-2222-2222-2222-222222222222", + sessionId: "sess-2", + turnId: "turn-2", + timestamp: "2026-06-18T00:00:05Z", + }, + ), + sessionUpdate( + 61, + { + sessionUpdate: "tool_call", + toolCallId: "call-out-of-order", + status: "executing", + title: "read_file", + kind: "read_file", + rawInput: { path: "AGENTS.md" }, + }, + { + channelId: "22222222-2222-2222-2222-222222222222", + sessionId: "sess-2", + turnId: "turn-2", + timestamp: "2026-06-18T00:00:04Z", + }, + ), + ]); + + assert.equal( + item.id, + "tool:22222222-2222-2222-2222-222222222222:call-out-of-order", + ); + assert.equal(item.status, "completed"); + assert.deepEqual(item.args, { path: "AGENTS.md" }); + assert.equal(item.channelId, "22222222-2222-2222-2222-222222222222"); + assert.equal(item.turnId, "turn-2"); + assert.equal(item.sessionId, "sess-2"); +}); + +test("buildTranscript coalesces assistant chunks until the message is sealed", () => { + const messages = buildTranscript([ + assistantChunk(70, "msg-1", "Hello "), + assistantChunk(71, "msg-1", "world"), + ]).filter((item) => item.type === "message" && item.role === "assistant"); + + assert.equal(messages.length, 1); + assert.equal(messages[0].text, "Hello world"); + assert.equal(messages[0].id, `assistant:${baseEvent.channelId}:msg-1`); +}); + +test("buildTranscript starts a continuation for same-message chunks after sealing", () => { + const messages = buildTranscript([ + assistantChunk(80, "msg-2", "First"), + acpToolUpdate(81, { + sessionUpdate: "tool_call", + toolCallId: "call-seal", + status: "executing", + title: "shell", + kind: "shell", + }), + assistantChunk(82, "msg-2", "Second"), + ]).filter((item) => item.type === "message" && item.role === "assistant"); + + assert.equal(messages.length, 2); + assert.equal(messages[0].text, "First"); + assert.equal(messages[1].text, "Second"); + assert.match(messages[1].id, /:c\d+$/); +}); + +test("buildTranscript preserves channel, turn, and session ids through message updates", () => { + const [message] = buildTranscript([ + assistantChunk(90, "msg-identity", "One ", { + channelId: "33333333-3333-3333-3333-333333333333", + sessionId: "sess-identity", + turnId: "turn-identity", + }), + assistantChunk(91, "msg-identity", "Two", { + channelId: "33333333-3333-3333-3333-333333333333", + sessionId: null, + turnId: null, + }), + ]).filter((item) => item.type === "message" && item.role === "assistant"); + + assert.equal(message.text, "One Two"); + assert.equal(message.channelId, "33333333-3333-3333-3333-333333333333"); + assert.equal(message.turnId, "turn-identity"); + assert.equal(message.sessionId, "sess-identity"); +}); + +test("buildTranscript promotes ACP plan updates to first-class plan items", () => { + const items = buildTranscript([ + sessionUpdate(90, { + sessionUpdate: "plan", + content: { type: "text", text: "- [ ] Build registry" }, + }), + ]); + + assert.equal(items.length, 1); + assert.equal(items[0].type, "plan"); + assert.equal(items[0].renderClass, "plan"); + assert.match(items[0].text, /Build registry/); +}); + +test("buildTranscript stores first-class render class descriptors for tool items", () => { + const [item] = toolItems([ + acpToolUpdate(91, { + sessionUpdate: "tool_call", + toolCallId: "call-edit", + status: "completed", + title: "str_replace", + kind: "str_replace", + rawInput: { path: "src/app.ts" }, + }), + ]); + + assert.equal(item.renderClass, "file-edit"); + assert.equal(item.descriptor.label, "Edited file"); + assert.equal(item.descriptor.preview, "src/app.ts"); +}); + +test("buildTranscript surfaces session/request_permission as a permission lifecycle item", () => { + const transcript = buildTranscript([ + { + seq: 1, + timestamp: "2026-06-30T09:00:00.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { + jsonrpc: "2.0", + method: "session/request_permission", + params: { + toolCallId: "tool-1", + title: "Confirm force-with-lease push to block/buzz.", + options: [ + { optionId: "allow_once", kind: "allow_once", name: "Allow" }, + { optionId: "reject_once", kind: "reject_once", name: "Reject" }, + ], + }, + }, + }, + ]); + + assert.equal(transcript.length, 1); + assert.equal(transcript[0].type, "lifecycle"); + assert.equal(transcript[0].renderClass, "permission"); + assert.equal(transcript[0].title, "Permission requested"); + assert.match(transcript[0].text, /Confirm force-with-lease push/); +}); + +test("buildTranscript stamps completedAt when a terminal tool update is inserted first", () => { + const transcript = buildTranscript([ + { + seq: 1, + timestamp: "2026-06-30T09:00:00.000Z", + kind: "acp_read", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + toolName: "dev__shell", + status: "completed", + rawInput: { command: "echo hi" }, + content: [{ type: "text", text: "hi" }], + }, + }, + }, + }, + ]); + + assert.equal(transcript[0].type, "tool"); + assert.equal(transcript[0].completedAt, "2026-06-30T09:00:00.000Z"); +}); + +test("buildTranscript preserves permission, free-form status, and raw rail render classes", () => { + const transcript = buildTranscript([ + { + ...baseEvent, + seq: 1, + kind: "acp_read", + payload: { + method: "session/request_permission", + params: { + title: "Confirm force-with-lease push", + toolCallId: "tool-push", + options: [ + { optionId: "allow_once", kind: "allow_once", name: "Allow" }, + { optionId: "reject_once", kind: "reject_once", name: "Reject" }, + ], + }, + }, + }, + { + ...baseEvent, + seq: 2, + kind: "acp_read", + payload: { + type: "observer_connected", + title: "Observer connected", + text: "ACP stream attached", + }, + }, + { + ...baseEvent, + seq: 3, + kind: "raw_json_rpc", + payload: { + method: "workspace/diagnostic", + params: { ok: true }, + }, + }, + ]); + + const permissionItem = transcript.find( + (item) => + item.id.startsWith("permission:") && item.renderClass === "permission", + ); + assert.ok( + permissionItem, + "permission request should flow through the reducer", + ); + assert.doesNotMatch( + permissionItem.text, + /^Permission requested\b/, + "permission detail should not duplicate the row title", + ); + assert.ok( + transcript.some( + (item) => + item.renderClass === "status" && item.title === "Observer connected", + ), + "free-form status fixture should flow through the reducer", + ); + assert.ok( + transcript.some( + (item) => item.type === "metadata" && item.renderClass === "raw-rail", + ), + "raw_json_rpc fixture should flow through the reducer", + ); +}); + +test("buildTranscript separates repeated lifecycle text", () => { + const events = [ + { + seq: 1, + timestamp: "2026-06-30T09:00:00.000Z", + kind: "turn_error", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { outcome: "recovered", error: "first" }, + }, + { + seq: 2, + timestamp: "2026-06-30T09:00:01.000Z", + kind: "turn_error", + agentIndex: 0, + channelId: "channel-1", + sessionId: "session-1", + turnId: "turn-1", + payload: { outcome: "recovered", error: "second" }, + }, + ]; + + const [item] = buildTranscript(events); + assert.equal(item.type, "lifecycle"); + assert.equal(item.text, "recovered: first\nrecovered: second"); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 8e1cee499..cc30a5268 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -1,4 +1,6 @@ import type { + AgentActivityDescriptor, + AgentActivityRenderClass, ObserverEvent, PromptSection, ToolStatus, @@ -9,13 +11,15 @@ import { isGenericToolTitle, normalizeToolStatus, } from "./agentSessionToolCatalog"; -import { asRecord, asString } from "./agentSessionUtils"; +import { classifyTool } from "./agentSessionToolClassifier"; +import { asRecord, asString, titleCase } from "./agentSessionUtils"; import { describeTurnStarted, describeSessionResolved, extractBlockText, extractContentText, extractPromptText, + extractTriggeringEventIds, extractToolArgs, extractToolIdentity, extractToolResult, @@ -30,6 +34,7 @@ export type TranscriptState = { itemsById: Map; activeMessageKey: Map; sealedKeys: Set; + triggeringEventIdsByTurn: Map; continuationSeq: number; latestSessionId: string | null; }; @@ -40,6 +45,7 @@ export function createEmptyTranscriptState(): TranscriptState { itemsById: new Map(), activeMessageKey: new Map(), sealedKeys: new Set(), + triggeringEventIdsByTurn: new Map(), continuationSeq: 0, latestSessionId: null, }; @@ -55,6 +61,7 @@ type TranscriptDraft = { itemsById: Map; activeMessageKey: Map; sealedKeys: Set; + triggeringEventIdsByTurn: Map; continuationSeq: number; latestSessionId: string | null; changed: boolean; @@ -66,6 +73,7 @@ function draftFrom(state: TranscriptState): TranscriptDraft { itemsById: state.itemsById, activeMessageKey: state.activeMessageKey, sealedKeys: state.sealedKeys, + triggeringEventIdsByTurn: state.triggeringEventIdsByTurn, continuationSeq: state.continuationSeq, latestSessionId: state.latestSessionId, changed: false, @@ -109,6 +117,104 @@ function sealOpenMessages(d: TranscriptDraft) { } } +function turnMapKey(channelKey: string, turnKey: string | number | null) { + return `${channelKey}:${turnKey ?? "unknown"}`; +} + +function rememberTriggeringEventIds( + d: TranscriptDraft, + channelKey: string, + turnKey: string | number | null, + ids: string[], +) { + if (ids.length === 0) return; + d.triggeringEventIdsByTurn = new Map(d.triggeringEventIdsByTurn); + d.triggeringEventIdsByTurn.set(turnMapKey(channelKey, turnKey), ids); +} + +function getSingleTriggeringEventId( + d: TranscriptDraft, + channelKey: string, + turnKey: string | number | null, +) { + const ids = d.triggeringEventIdsByTurn.get(turnMapKey(channelKey, turnKey)); + return ids?.length === 1 ? maybeNostrEventId(ids[0]) : null; +} + +function maybeNostrEventId(id: string | null | undefined) { + return id && /^[0-9a-fA-F]{64}$/.test(id) ? id : null; +} + +function stringifyPayload(value: unknown) { + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + return String(value); + } +} + +function describePermissionRequest(payload: Record) { + const params = asRecord(payload.params); + const title = + asString(params.title) ?? + asString(params.message) ?? + asString(params.reason) ?? + "Permission requested"; + const toolCallId = + asString(params.toolCallId) ?? asString(params.tool_call_id); + const options = Array.isArray(params.options) + ? params.options + .map((option) => { + const record = asRecord(option); + return ( + asString(record.name) ?? + asString(record.kind) ?? + asString(record.optionId) + ); + }) + .filter((option): option is string => Boolean(option)) + : []; + const detail: string[] = []; + if (title !== "Permission requested") detail.push(title); + if (toolCallId) detail.push(`Tool call: ${toolCallId}`); + if (options.length > 0) detail.push(`Options: ${options.join(", ")}`); + return { + title, + text: detail.join("\n"), + descriptor: { + renderClass: "permission" as const, + label: "Permission requested", + preview: title, + action: { verb: "Requested", object: title }, + tone: "admin" as const, + operation: "session/request_permission", + object: title, + source: "acp" as const, + groupKey: "permission:request", + }, + }; +} + +function describeFreeformStatus(payload: Record) { + const statusType = asString(payload.type) ?? asString(payload.status); + const title = + asString(payload.title) ?? (statusType ? titleCase(statusType) : null); + const text = asString(payload.text) ?? asString(payload.message); + if (!title || !text) return null; + return { statusType: statusType ?? title.toLowerCase(), title, text }; +} + +function rawPayloadTitle(payload: unknown) { + const record = asRecord(payload); + return asString(record.method) ?? asString(record.type) ?? "raw_json_rpc"; +} + +type TranscriptItemContext = { + channelId: string | null; + turnId: string | null; + sessionId: string | null; +}; + function upsertMessage( d: TranscriptDraft, id: string, @@ -116,8 +222,10 @@ function upsertMessage( title: string, text: string, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, authorPubkey: string | null = null, + acpSource?: string, + messageId: string | null = null, ) { const currentKey = d.activeMessageKey.get(id); @@ -127,8 +235,12 @@ 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, + messageId: messageId ?? existing.messageId, }); return; } @@ -139,12 +251,17 @@ function upsertMessage( pushItem(d, { id: newKey, type: "message", + renderClass: "message", role, title, text, timestamp, - channelId, + messageId, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, authorPubkey, + acpSource, }); d.activeMessageKey = new Map(d.activeMessageKey); d.activeMessageKey.set(id, newKey); @@ -157,15 +274,172 @@ function upsertTextItem( title: string, text: string, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, + 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: + type === "lifecycle" + ? joinLifecycleText(existing.text, text) + : existing.text + text, + 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 }); + if (type === "thought") { + pushItem(d, { + id, + type: "thought", + renderClass: "thought", + title, + text, + timestamp, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, + }); + return; + } + + upsertLifecycleItem( + d, + id, + title.toLowerCase().includes("error") ? "error" : "status", + title, + text, + timestamp, + ctx, + acpSource, + ); +} + +function joinLifecycleText(existing: string, next: string) { + if (!existing) return next; + if (!next) return existing; + return `${existing}\n${next}`; +} + +function upsertLifecycleItem( + d: TranscriptDraft, + id: string, + renderClass: Extract< + AgentActivityRenderClass, + "status" | "permission" | "error" + >, + title: string, + text: string, + timestamp: string, + ctx: TranscriptItemContext, + acpSource?: string, + descriptor?: AgentActivityDescriptor, +) { + const existing = d.itemsById.get(id); + if (existing?.type === "lifecycle") { + replaceItem(d, id, { + ...existing, + renderClass, + title, + text: joinLifecycleText(existing.text, text), + descriptor: descriptor ?? existing.descriptor, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, + acpSource: acpSource ?? existing.acpSource, + }); + return; + } + + sealOpenMessages(d); + pushItem(d, { + id, + type: "lifecycle", + renderClass, + title, + text, + timestamp, + descriptor, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, + }); +} + +function upsertPlan( + d: TranscriptDraft, + id: string, + title: string, + text: string, + timestamp: string, + ctx: TranscriptItemContext, + acpSource?: string, + updateMarkerId?: string, +) { + const existing = d.itemsById.get(id); + if (existing?.type === "plan") { + const changed = existing.text !== text; + replaceItem(d, id, { + ...existing, + text, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, + acpSource: acpSource ?? existing.acpSource, + }); + if (changed) { + pushItem(d, { + id: updateMarkerId ?? `${id}:update:${timestamp}`, + type: "plan", + renderClass: "plan", + title: "Plan updated", + text: summarizePlanUpdate(text), + timestamp, + isUpdate: true, + targetId: id, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, + }); + } + return; + } + sealOpenMessages(d); + pushItem(d, { + id, + type: "plan", + renderClass: "plan", + title, + text, + timestamp, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, + }); +} + +function summarizePlanUpdate(text: string) { + const taskMatches = [...text.matchAll(/\[[ xX]\]/g)]; + if (taskMatches.length > 0) { + const completed = taskMatches.filter((match) => + match[0].toLowerCase().includes("x"), + ).length; + return `${completed}/${taskMatches.length} complete`; + } + + const stepCount = text + .split(/\r?\n/) + .filter((line) => /^\s*(?:[-*]|\d+[.)])\s+\S/.test(line)).length; + return stepCount > 0 ? `${stepCount} step${stepCount === 1 ? "" : "s"}` : ""; } function upsertMetadata( @@ -174,15 +448,46 @@ function upsertMetadata( title: string, sections: PromptSection[], timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, + acpSource?: string, ) { const existing = d.itemsById.get(id); if (existing?.type === "metadata") { - replaceItem(d, id, { ...existing, sections, channelId }); + replaceItem(d, id, { + ...existing, + sections, + channelId: ctx.channelId, + turnId: ctx.turnId ?? existing.turnId, + sessionId: ctx.sessionId ?? existing.sessionId, + acpSource: acpSource ?? existing.acpSource, + }); return; } sealOpenMessages(d); - pushItem(d, { id, type: "metadata", title, sections, timestamp, channelId }); + pushItem(d, { + id, + type: "metadata", + renderClass: "raw-rail", + title, + sections, + timestamp, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, + }); +} + +function isTerminalToolStatus(status: ToolStatus) { + return status === "completed" || status === "failed"; +} + +function mergeToolStatus(existing: ToolStatus, next: ToolStatus): ToolStatus { + if (isTerminalToolStatus(existing) && !isTerminalToolStatus(next)) { + return existing; + } + + return next; } function upsertTool( @@ -196,7 +501,8 @@ function upsertTool( result: string, isError: boolean, timestamp: string, - channelId: string | null, + ctx: TranscriptItemContext, + acpSource?: string, ) { const existing = d.itemsById.get(id); const canonicalBuzzToolName = @@ -211,30 +517,57 @@ function upsertTool( } else if (!existing.buzzToolName && !isGenericToolTitle(toolName)) { updatedToolName = toolName; } + const mergedStatus = mergeToolStatus(existing.status, status); + const updatedArgs = Object.keys(args).length > 0 ? args : existing.args; + const updatedResult = result || existing.result; + const updatedIsError = isError || existing.isError; + const descriptor = classifyTool({ + title: updatedTitle, + toolName: updatedToolName, + buzzToolName: updatedBuzzToolName, + args: updatedArgs, + result: updatedResult, + isError: updatedIsError || mergedStatus === "failed", + }); replaceItem(d, id, { ...existing, + renderClass: descriptor.renderClass, + descriptor, title: updatedTitle, toolName: updatedToolName, buzzToolName: updatedBuzzToolName, - status, - args: Object.keys(args).length > 0 ? args : existing.args, - result: result || existing.result, - isError: isError || existing.isError, + status: mergedStatus, + args: updatedArgs, + result: updatedResult, + isError: updatedIsError, completedAt: - (status === "completed" || status === "failed") && - existing.completedAt == null + isTerminalToolStatus(mergedStatus) && 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; } + const resolvedToolName = canonicalBuzzToolName ?? toolName; + const descriptor = classifyTool({ + title, + toolName: resolvedToolName, + buzzToolName: canonicalBuzzToolName, + args, + result, + isError: isError || status === "failed", + }); sealOpenMessages(d); pushItem(d, { id, type: "tool", + renderClass: descriptor.renderClass, + descriptor, title, - toolName: canonicalBuzzToolName ?? toolName, + toolName: resolvedToolName, buzzToolName: canonicalBuzzToolName, status, args, @@ -242,8 +575,11 @@ function upsertTool( isError, timestamp, startedAt: timestamp, - completedAt: null, - channelId, + completedAt: isTerminalToolStatus(status) ? timestamp : null, + channelId: ctx.channelId, + turnId: ctx.turnId, + sessionId: ctx.sessionId, + acpSource, }); } @@ -259,8 +595,34 @@ 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") { + if (event.kind === "raw_json_rpc") { + upsertMetadata( + d, + `raw-json-rpc:${ch}:${event.seq}`, + "Raw ACP payload", + [ + { + title: rawPayloadTitle(event.payload), + body: stringifyPayload(event.payload), + }, + ], + event.timestamp, + ctx, + event.kind, + ); + } else if (event.kind === "turn_started") { + rememberTriggeringEventIds( + d, + ch, + event.turnId ?? event.seq, + extractTriggeringEventIds(event.payload), + ); upsertTextItem( d, `turn:${ch}:${event.turnId ?? event.seq}`, @@ -268,7 +630,8 @@ export function processTranscriptEvent( "Turn started", describeTurnStarted(event.payload), event.timestamp, - channelId, + ctx, + event.kind, ); } else if (event.kind === "session_resolved") { upsertTextItem( @@ -278,7 +641,8 @@ export function processTranscriptEvent( "Session ready", describeSessionResolved(event.payload), event.timestamp, - channelId, + ctx, + event.kind, ); } else if (event.kind === "acp_parse_error") { upsertTextItem( @@ -288,7 +652,8 @@ 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") { const payload = asRecord(event.payload); @@ -303,13 +668,27 @@ export function processTranscriptEvent( title, `${outcome}: ${error}`, event.timestamp, - channelId, + ctx, + event.kind, ); } else if (event.kind === "acp_read" || event.kind === "acp_write") { const payload = asRecord(event.payload); const method = asString(payload.method); - if (event.kind === "acp_write" && method === "session/prompt") { + if (method === "session/request_permission") { + const request = describePermissionRequest(payload); + upsertLifecycleItem( + d, + `permission:${ch}:${event.turnId ?? event.seq}`, + "permission", + "Permission requested", + request.text, + event.timestamp, + ctx, + "permission_request", + request.descriptor, + ); + } else if (event.kind === "acp_write" && method === "session/prompt") { const promptText = extractPromptText(payload); if (promptText) { const parsedPrompt = parsePromptText(promptText); @@ -321,8 +700,11 @@ export function processTranscriptEvent( parsedPrompt.userTitle, parsedPrompt.userText, event.timestamp, - channelId, + ctx, parsedPrompt.userPubkey, + "session/prompt:user", + parsedPrompt.userEventId ?? + getSingleTriggeringEventId(d, ch, event.turnId ?? event.seq), ); } if (parsedPrompt.sections.length > 0) { @@ -332,7 +714,8 @@ export function processTranscriptEvent( "Prompt context", parsedPrompt.sections, event.timestamp, - channelId, + ctx, + "session/prompt:context", ); } } @@ -353,7 +736,7 @@ export function processTranscriptEvent( "System prompt", sections, event.timestamp, - channelId, + ctx, ); } } @@ -372,8 +755,10 @@ export function processTranscriptEvent( parsedPrompt.userTitle, parsedPrompt.userText, event.timestamp, - channelId, + ctx, parsedPrompt.userPubkey, + undefined, + parsedPrompt.userEventId, ); } if (parsedPrompt.sections.length > 0) { @@ -383,7 +768,7 @@ export function processTranscriptEvent( "Prompt context", parsedPrompt.sections, event.timestamp, - channelId, + ctx, ); } } @@ -402,13 +787,17 @@ export function processTranscriptEvent( "Assistant", extractContentText(update.content), event.timestamp, - channelId, + ctx, + null, + updateType, ); } else if (updateType === "user_message_chunk") { // Suppress user_message_chunk echo when a steer already rendered // the user message for this turn (Goose echoes steered content back). const steerKey = `steer:${ch}:${event.turnId ?? event.seq}`; + const authorPubkey = asString(update.authorPubkey); if (!d.itemsById.has(steerKey)) { + const channelMessageId = maybeNostrEventId(messageId); upsertMessage( d, `user:${ch}:${messageId ?? turnKey}`, @@ -416,7 +805,10 @@ export function processTranscriptEvent( "User", extractContentText(update.content), event.timestamp, - channelId, + ctx, + authorPubkey, + updateType, + channelMessageId, ); } } else if (updateType === "agent_thought_chunk") { @@ -427,7 +819,8 @@ export function processTranscriptEvent( "Thinking", extractContentText(update.content), event.timestamp, - channelId, + ctx, + updateType, ); } else if (updateType === "tool_call") { const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`; @@ -443,7 +836,8 @@ export function processTranscriptEvent( extractToolResult(update), false, event.timestamp, - channelId, + ctx, + updateType, ); } else if (updateType === "tool_call_update") { const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`; @@ -462,17 +856,53 @@ export function processTranscriptEvent( extractToolResult(update), status === "failed", event.timestamp, - channelId, + ctx, + updateType, ); } else if (updateType === "plan") { - upsertTextItem( + upsertPlan( d, `plan:${ch}:${turnKey}`, - "thought", "Plan", extractContentText(update.content) || JSON.stringify(update, null, 2), event.timestamp, - channelId, + ctx, + updateType, + `plan-update:${ch}:${turnKey}:${event.seq}`, + ); + } else { + // Free-form observer status records are not part of the ACP session/update + // union. Surface only explicit title/text payloads; leave all other + // unknown frames out of the feed instead of guessing at semantics. + const status = describeFreeformStatus(payload); + if (status) { + upsertLifecycleItem( + d, + `status:${ch}:${event.turnId ?? event.seq}:${status.statusType}`, + "status", + status.title, + status.text, + event.timestamp, + ctx, + status.statusType, + ); + } + } + } else { + // Free-form observer status records are not part of the ACP JSON-RPC + // method set. Surface only explicit title/text payloads; leave all other + // unknown frames out of the feed instead of guessing at semantics. + const status = describeFreeformStatus(payload); + if (status) { + upsertLifecycleItem( + d, + `status:${ch}:${event.turnId ?? event.seq}:${status.statusType}`, + "status", + status.title, + status.text, + event.timestamp, + ctx, + status.statusType, ); } } @@ -487,6 +917,7 @@ export function processTranscriptEvent( itemsById: d.itemsById, activeMessageKey: d.activeMessageKey, sealedKeys: d.sealedKeys, + triggeringEventIdsByTurn: d.triggeringEventIdsByTurn, continuationSeq: d.continuationSeq, latestSessionId: d.latestSessionId, }; 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..e16f1cd77 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs @@ -0,0 +1,327 @@ +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"); + assert.equal(turnBlock.segments[2]?.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 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, ["tool"]); +}); + +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); + + assert.deepEqual(blocks, []); +}); + +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"); +}); + +test("buildTranscriptDisplayBlocks groups same-kind tool runs within a turn", () => { + const items = [1, 2, 3].map((index) => ({ + id: `tool:${index}`, + type: "tool", + renderClass: "generic", + descriptor: { + renderClass: "generic", + label: "Read file", + preview: `file-${index}.ts`, + groupKey: "read_file", + }, + title: "read_file", + toolName: "read_file", + buzzToolName: null, + status: "completed", + args: { path: `file-${index}.ts` }, + result: "", + isError: false, + timestamp: "2026-06-18T00:00:00Z", + startedAt: "2026-06-18T00:00:00Z", + completedAt: "2026-06-18T00:00:01Z", + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + })); + + const [block] = buildTranscriptDisplayBlocks(items); + + assert.equal(block.kind, "turn"); + assert.equal(block.segments.length, 1); + assert.equal(block.segments[0].kind, "summary"); + assert.equal(block.segments[0].summary.label, "Read 3 files"); +}); + +test("buildTranscriptDisplayBlocks groups consecutive file edit tool runs", () => { + const items = [1, 2].map((index) => ({ + id: `edit:${index}`, + type: "tool", + renderClass: "file-edit", + descriptor: { + renderClass: "file-edit", + label: "Edited file", + preview: `src/file-${index}.ts`, + groupKey: "file-edit:str_replace", + }, + title: "str_replace", + toolName: "str_replace", + buzzToolName: null, + status: "completed", + args: { path: `src/file-${index}.ts` }, + result: "", + isError: false, + timestamp: "2026-06-18T00:00:00Z", + startedAt: "2026-06-18T00:00:00Z", + completedAt: "2026-06-18T00:00:01Z", + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + })); + + const [block] = buildTranscriptDisplayBlocks(items); + + assert.equal(block.kind, "turn"); + assert.equal(block.segments.length, 1); + assert.equal(block.segments[0].kind, "summary"); + assert.equal(block.segments[0].summary.label, "Edited 2 files"); + assert.equal(block.segments[0].summary.renderClass, "file-edit"); + assert.deepEqual( + block.segments[0].summary.items.map((item) => item.id), + ["edit:1", "edit:2"], + ); +}); + +test("buildTranscriptDisplayBlocks keeps non-contiguous same-kind runs expanded", () => { + const mkTool = (id, label, renderClass = "generic", groupKey = label) => ({ + id, + type: "tool", + renderClass, + descriptor: { + renderClass, + label, + preview: id, + source: "harness", + groupKey, + }, + title: label, + toolName: label, + buzzToolName: null, + status: "completed", + args: {}, + result: "", + isError: false, + timestamp: "2026-06-18T00:00:00Z", + startedAt: "2026-06-18T00:00:00Z", + completedAt: "2026-06-18T00:00:01Z", + turnId: "turn-1", + sessionId: "sess-1", + channelId: "chan-1", + }); + + const [block] = buildTranscriptDisplayBlocks([ + mkTool("read-1", "Read file", "generic", "read_file"), + mkTool("shell-1", "Ran command", "shell", "shell:command"), + mkTool("read-2", "Read file", "generic", "read_file"), + mkTool("read-3", "Read file", "generic", "read_file"), + ]); + + assert.equal(block.kind, "turn"); + assert.deepEqual( + block.segments.map((segment) => segment.kind), + ["item", "item", "item", "item"], + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts new file mode 100644 index 000000000..39869b06a --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -0,0 +1,291 @@ +import type { TranscriptItem } from "./agentSessionTypes"; +import { classifyToolItem } from "./agentSessionToolClassifier"; + +export type TranscriptTurnSegment = + | { kind: "item"; item: TranscriptItem } + | { kind: "summary"; summary: TranscriptSameKindSummary } + | { 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[] }; + +export type TranscriptSameKindSummary = { + id: string; + label: string; + count: number; + items: TranscriptItem[]; + renderClass: TranscriptItem["renderClass"] | null; + timestamp: string; +}; + +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") + ); +} + +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 groupSameKindSegments( + activity.map((item) => ({ kind: "item", item })), + ); + } + + const segments: TranscriptTurnSegment[] = [ + { + kind: "prompt", + user: userPrompt, + context: promptContext, + setup: setupLifecycle, + }, + ]; + + for (const item of activity) { + segments.push({ kind: "item", item }); + } + + return groupSameKindSegments(segments); +} + +function groupSameKindSegments( + segments: TranscriptTurnSegment[], +): TranscriptTurnSegment[] { + const grouped: TranscriptTurnSegment[] = []; + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + if (segment.kind !== "item") { + grouped.push(segment); + continue; + } + const key = sameKindKey(segment.item); + if (!key) { + grouped.push(segment); + continue; + } + const run = [segment.item]; + let j = i + 1; + while (j < segments.length) { + const next = segments[j]; + if (next.kind !== "item" || sameKindKey(next.item) !== key) break; + run.push(next.item); + j += 1; + } + if (run.length >= minimumSummaryRunLength(run[0])) { + grouped.push({ + kind: "summary", + summary: { + id: `summary:${key}:${run[0].id}`, + label: sameKindLabel(run[0], run.length), + count: run.length, + items: run, + renderClass: getRenderClass(run[0]), + timestamp: run[0].timestamp, + }, + }); + i = j - 1; + } else { + grouped.push(...run.map((item) => ({ kind: "item" as const, item }))); + i = j - 1; + } + } + return grouped; +} + +function sameKindKey(item: TranscriptItem): string | null { + if (item.type !== "tool") return null; + const renderClass = getRenderClass(item); + if (renderClass === "message") { + return null; + } + const descriptor = item.descriptor ?? classifyToolItem(item); + return descriptor.groupKey ?? renderClass; +} + +function sameKindLabel(item: TranscriptItem, count: number): string { + if (item.type !== "tool") return `${count} items`; + const descriptor = item.descriptor ?? classifyToolItem(item); + const renderClass = getRenderClass(item); + const label = descriptor.label; + if (renderClass === "file-edit") { + return `Edited ${count} file${count === 1 ? "" : "s"}`; + } + if (label === "Read file") return `Read ${count} files`; + if (label === "Ran command") return `Ran ${count} commands`; + if (renderClass === "relay-op") return `Ran ${count} Buzz relay ops`; + return `${label} ×${count}`; +} + +function minimumSummaryRunLength(item: TranscriptItem): number { + return getRenderClass(item) === "file-edit" ? 2 : 3; +} + +function getRenderClass(item: TranscriptItem) { + if (item.type !== "tool") return item.renderClass; + const descriptor = item.descriptor ?? classifyToolItem(item); + return item.renderClass ?? descriptor.renderClass; +} + +/** + * 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(); + // Callers pass a channel-scoped item stream; revisit this bare turnId bucket + // if grouping ever receives multi-channel transcript items. + 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; + } + + const segments = classifyTurnItems(bucket.items); + if (segments.length > 0) { + blocks.push({ + kind: "turn", + turnId: entry.turnId, + segments, + }); + } + } + + 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 if (segment.kind === "summary") { + result.push(...segment.summary.items); + } 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/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index 2d9ed410b..70b2bfffa 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -20,6 +20,7 @@ test("parsePromptText returns the empty/Prompt fallback for whitespace-only inpu userText: "", userTitle: "Prompt", userPubkey: null, + userEventId: null, }); }); @@ -36,14 +37,16 @@ test("parsePromptText wraps header-less free text in a single Prompt section", ( assert.equal(result.userText, ""); assert.equal(result.userTitle, "Buzz event"); assert.equal(result.userPubkey, null); + assert.equal(result.userEventId, null); }); -test("parsePromptText extracts content, hex pubkey, and a title-cased kind", () => { +test("parsePromptText extracts event id, content, hex pubkey, and a title-cased kind", () => { const text = [ "[System]", "system preamble here", "", "[Buzz event: @mention]", + `Event ID: ${HEX_UPPER}`, "Channel: demo", `From: Wes (hex: ${HEX})`, "Content: hello @Brain please look", @@ -53,6 +56,7 @@ test("parsePromptText extracts content, hex pubkey, and a title-cased kind", () assert.equal(result.userText, "hello @Brain please look"); assert.equal(result.userPubkey, HEX); + assert.equal(result.userEventId, HEX); // titleCase capitalizes after word boundaries but leaves the leading "@" // (a non-word char) in place: "@mention" -> "@Mention". assert.equal(result.userTitle, "@Mention"); @@ -63,6 +67,38 @@ 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); + assert.equal(result.userEventId, null); +}); + 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 857c24c63..5daf00d2b 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -18,6 +18,7 @@ export function parsePromptText(text: string): { userText: string; userTitle: string; userPubkey: string | null; + userEventId: string | null; } { const sections = parsePromptSections(text).filter( (s) => s.body.trim().length > 0, @@ -28,12 +29,13 @@ export function parsePromptText(text: string): { userText: text.trim(), userTitle: "Prompt", userPubkey: null, + userEventId: null, }; } const eventSection = sections.find((section) => { const title = section.title.toLowerCase(); - return title.startsWith("buzz event") || title.startsWith("buzz event"); + return title.startsWith("buzz event"); }); const eventContent = eventSection ? extractEventContent(eventSection.body) @@ -41,6 +43,7 @@ export function parsePromptText(text: string): { const eventAuthorPubkey = eventSection ? extractEventAuthorPubkey(eventSection.body) : null; + const eventId = eventSection ? extractEventId(eventSection.body) : null; const eventKind = eventSection?.title.split(":").slice(1).join(":").trim(); return { @@ -48,6 +51,7 @@ export function parsePromptText(text: string): { userText: eventContent, userTitle: eventKind ? titleCase(eventKind) : "Buzz event", userPubkey: eventAuthorPubkey, + userEventId: eventId, }; } @@ -127,9 +131,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 { @@ -137,6 +171,11 @@ function extractEventAuthorPubkey(body: string): string | null { return fromMatch?.[1]?.toLowerCase() ?? null; } +function extractEventId(body: string): string | null { + const eventIdMatch = body.match(/^Event ID:\s*([0-9a-fA-F]{64})\b/m); + return eventIdMatch?.[1]?.toLowerCase() ?? null; +} + export function extractContentText(value: unknown): string { if (typeof value === "string") return value; if (Array.isArray(value)) return value.map(extractBlockText).join("\n"); @@ -242,13 +281,17 @@ export function extractToolResult(update: Record): string { return extractBlockText(update.rawOutput); } -export function describeTurnStarted(payload: unknown): string { +export function extractTriggeringEventIds(payload: unknown): string[] { const record = asRecord(payload); - const ids = Array.isArray(record.triggeringEventIds) + return Array.isArray(record.triggeringEventIds) ? record.triggeringEventIds.filter( (id): id is string => typeof id === "string", ) : []; +} + +export function describeTurnStarted(payload: unknown): string { + const ids = extractTriggeringEventIds(payload); return ids.length > 0 ? `Triggered by ${ids.length === 1 ? "1 event" : `${ids.length} events`}.` : ""; 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..8cf7a59cb --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + 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 · abc"); + 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("getActivityHeadline uses semantic tool descriptors", () => { + assert.equal( + getActivityHeadline( + makeTool({ + title: "Shell", + toolName: "dev__shell", + buzzToolName: null, + args: { command: "buzz messages send --content hi" }, + descriptor: { + renderClass: "message", + label: "Send Message", + preview: "hi", + source: "shell", + groupKey: "buzz-cli:messages.send", + }, + }), + ), + "Send Message · hi", + ); +}); + +test("isMeaningfulItem ignores suppressed tools", () => { + assert.equal( + isMeaningfulItem( + makeTool({ + renderClass: "suppressed", + descriptor: { + renderClass: "suppressed", + label: "Checked todos", + preview: null, + source: "harness", + groupKey: "suppressed:stop-hook", + }, + }), + ), + false, + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts new file mode 100644 index 000000000..5fec4f269 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptPresentation.ts @@ -0,0 +1,62 @@ +import type { TranscriptItem } from "./agentSessionTypes"; +import { buildCompactToolSummary } from "./agentSessionToolSummary"; + +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") { + const summary = buildCompactToolSummary(item); + return [summary.label, summary.preview].filter(Boolean).join(" · "); + } + + 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 === "tool" && item.renderClass === "suppressed") { + return false; + } + if (item.type === "lifecycle") { + return !isLifecycleNoise(item); + } + if (item.type === "metadata") { + return false; + } + return true; +} diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts index 2ff4ea305..2fb65817c 100644 --- a/desktop/src/features/agents/ui/agentSessionTypes.ts +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -20,44 +20,107 @@ export type ConnectionState = export type ToolStatus = "executing" | "completed" | "failed" | "pending"; +export type AgentActivityRenderClass = + | "message" + | "relay-op" + | "file-edit" + | "shell" + | "status" + | "thought" + | "plan" + | "permission" + | "error" + | "generic" + | "raw-rail" + | "suppressed"; + +export type AgentActivityTone = "read" | "write" | "admin" | "neutral"; + +export type AgentActivityAction = { + verb: string; + object?: string | null; +}; + +export type AgentActivityDescriptor = { + renderClass: AgentActivityRenderClass; + label: string; + preview: string | null; + action?: AgentActivityAction; + tone?: AgentActivityTone; + operation?: string; + object?: string | null; + source?: "mcp" | "shell" | "acp" | "harness" | "fallback"; + groupKey?: string; + reason?: string; +}; + +/** 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"; + renderClass: "message"; role: "assistant" | "user"; title: string; text: string; timestamp: string; + messageId?: string | null; + acpSource?: TranscriptAcpSource; authorPubkey?: string | null; - channelId?: string | null; - } - | { + } & TranscriptItemIdentity) + | ({ id: string; type: "thought"; + renderClass: "thought"; + title: string; + text: string; + timestamp: string; + acpSource?: TranscriptAcpSource; + } & TranscriptItemIdentity) + | ({ + id: string; + type: "plan"; + renderClass: "plan"; title: string; text: string; timestamp: string; - channelId?: string | null; - } - | { + isUpdate?: boolean; + targetId?: string; + acpSource?: TranscriptAcpSource; + } & TranscriptItemIdentity) + | ({ id: string; type: "lifecycle"; + renderClass: "status" | "permission" | "error"; title: string; text: string; timestamp: string; - channelId?: string | null; - } - | { + descriptor?: AgentActivityDescriptor; + acpSource?: TranscriptAcpSource; + } & TranscriptItemIdentity) + | ({ id: string; type: "metadata"; + renderClass: "raw-rail"; title: string; sections: PromptSection[]; timestamp: string; - channelId?: string | null; - } - | { + acpSource?: TranscriptAcpSource; + } & TranscriptItemIdentity) + | ({ id: string; type: "tool"; + renderClass: AgentActivityRenderClass; + descriptor: AgentActivityDescriptor; title: string; toolName: string; buzzToolName: string | null; @@ -68,8 +131,8 @@ export type TranscriptItem = timestamp: string; startedAt: string; completedAt: string | null; - channelId?: string | null; - }; + acpSource?: TranscriptAcpSource; + } & TranscriptItemIdentity); export type PromptSection = { title: string; diff --git a/desktop/src/features/agents/ui/agentSessionUtils.ts b/desktop/src/features/agents/ui/agentSessionUtils.ts index 19d915ec8..76404eae8 100644 --- a/desktop/src/features/agents/ui/agentSessionUtils.ts +++ b/desktop/src/features/agents/ui/agentSessionUtils.ts @@ -50,6 +50,44 @@ export function formatCodeValue(value: string): string { } } +export type ShellToolOutput = { + exitCode: number | null; + raw: string; + stderr: string; + stdout: string; + timedOut: boolean; +}; + +export function parseShellToolOutput(result: string): ShellToolOutput { + const parsed = parseToolResultValue(result); + const record = asRecord(parsed); + const hasShellShape = + "stdout" in record || + "stderr" in record || + "exit_code" in record || + "exitCode" in record || + "timed_out" in record || + "timedOut" in record; + + if (!hasShellShape) { + return { + exitCode: null, + raw: typeof parsed === "string" ? parsed : result, + stderr: "", + stdout: "", + timedOut: false, + }; + } + + return { + exitCode: getToolNumber(record, ["exit_code", "exitCode"]), + raw: "", + stderr: getOptionalString(record, ["stderr"]), + stdout: getOptionalString(record, ["stdout"]), + timedOut: getOptionalBoolean(record, ["timed_out", "timedOut"]), + }; +} + export function titleCase(value: string): string { return value .replace(/[_-]+/g, " ") @@ -64,6 +102,117 @@ 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; +} + +function getOptionalBoolean( + record: Record, + keys: string[], +): boolean { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") { + return value; + } + } + return false; +} + +function getOptionalString( + record: Record, + keys: string[], +): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") { + return value; + } + } + return ""; +} + +/** 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; } @@ -80,14 +229,16 @@ export function shortenMiddle(value: string, maxLength: number) { return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`; } -const sameDayTimeFormat = new Intl.DateTimeFormat(undefined, { +const transcriptTimeFormat = new Intl.DateTimeFormat("en-US", { hour: "numeric", + hour12: true, minute: "2-digit", - second: "2-digit", }); -const crossDayTimeFormat = new Intl.DateTimeFormat(undefined, { - month: "short", +const transcriptTitleTimeFormat = new Intl.DateTimeFormat(undefined, { + weekday: "long", + year: "numeric", + month: "long", day: "numeric", hour: "numeric", minute: "2-digit", @@ -97,14 +248,15 @@ const crossDayTimeFormat = new Intl.DateTimeFormat(undefined, { export function formatTranscriptTime(isoTimestamp: string): string | null { const date = new Date(isoTimestamp); if (Number.isNaN(date.getTime())) return null; - const now = new Date(); - const sameDay = - date.getFullYear() === now.getFullYear() && - date.getMonth() === now.getMonth() && - date.getDate() === now.getDate(); - return sameDay - ? sameDayTimeFormat.format(date) - : crossDayTimeFormat.format(date); + return transcriptTimeFormat.format(date); +} + +export function formatTranscriptTimestampTitle( + isoTimestamp: string, +): string | undefined { + const date = new Date(isoTimestamp); + if (Number.isNaN(date.getTime())) return isoTimestamp || undefined; + return transcriptTitleTimeFormat.format(date); } export function formatDuration( 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"); +}); diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 301ef12b6..572c7a780 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -1,4 +1,5 @@ -import { Octagon, Settings } from "lucide-react"; +import * as React from "react"; +import { Octagon, Settings, TerminalSquare } from "lucide-react"; import { toast } from "sonner"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; @@ -20,8 +21,10 @@ import { Button } from "@/shared/ui/button"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; @@ -59,7 +62,19 @@ export function AgentSessionThreadPanel({ useEscapeKey(onClose, isOverlay || isSinglePanelView); const { ref: scrollRef, onScroll } = useStickToBottom(); - + const rawFeedScopeKey = `${agent.pubkey}:${channel?.id ?? "all"}`; + 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() { if (!channel) { return; @@ -81,7 +96,7 @@ export function AgentSessionThreadPanel({ const agentHeaderActions = ( - {isLive && isWorking ? ( + {isLive ? (