diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 823852142..773c9dd80 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -7,7 +7,11 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import type { TranscriptItem } from "./agentSessionTypes"; import { getBuzzToolInfo } from "./agentSessionToolCatalog"; import { buildCompactToolSummary } from "./agentSessionToolSummary"; -import { asRecord, formatCodeValue, formatDuration } from "./agentSessionUtils"; +import { + formatCodeValue, + getToolDurationDisplay, + isInlineImageData, +} from "./agentSessionUtils"; export function ToolItem({ item, @@ -20,7 +24,7 @@ export function ToolItem({ const canonicalToolName = item.buzzToolName ?? item.toolName; const buzzTool = getBuzzToolInfo(canonicalToolName); const compactSummary = buildCompactToolSummary(item); - const duration = getToolDuration(item); + const duration = getToolDurationDisplay(item); const handleToggle = React.useCallback( (event: React.SyntheticEvent) => { setIsExpanded(event.currentTarget.open); @@ -88,6 +92,10 @@ function compactSummaryTone() { return "text-muted-foreground/60 group-open:text-muted-foreground"; } +function resolveImageSrc(source: string): string { + return isInlineImageData(source) ? source : rewriteRelayUrl(source); +} + function CompactToolSummaryRow({ duration, label, @@ -188,13 +196,6 @@ function CompactMessageSummary({ ); } -function resolveImageSrc(source: string): string { - if (source.startsWith("data:image/")) { - return source; - } - return rewriteRelayUrl(source); -} - function ViewImageToolPreview({ src, title, @@ -294,48 +295,6 @@ function ImageLightbox({ ); } -function getToolDuration(item: Extract) { - if (item.startedAt && item.completedAt) { - return formatDuration(item.startedAt, item.completedAt); - } - - const resultRecord = asRecord(parseToolResultValue(item.result)); - const durationMs = - getToolNumber(resultRecord, ["duration_ms", "durationMs"]) ?? - getToolNumber(resultRecord, ["elapsed_ms", "elapsedMs"]); - return durationMs == null ? null : formatDurationMs(durationMs); -} - -function getToolNumber( - record: Record, - keys: string[], -): number | null { - for (const key of keys) { - const value = record[key]; - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - } - return null; -} - -function formatDurationMs(ms: number) { - if (ms < 0) return null; - const totalSeconds = ms / 1000; - if (totalSeconds < 60) { - return totalSeconds < 10 - ? `${totalSeconds.toFixed(1)}s` - : `${Math.round(totalSeconds)}s`; - } - let minutes = Math.floor(totalSeconds / 60); - let seconds = Math.round(totalSeconds % 60); - if (seconds === 60) { - minutes += 1; - seconds = 0; - } - return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; -} - function ToolDetailBlocks({ args, description, @@ -416,20 +375,3 @@ function ToolCodeBlock({ ); } - -function parseToolResultValue(result: string): unknown { - const trimmed = result.trim(); - if (!trimmed) return null; - - try { - const parsed = JSON.parse(trimmed); - if (typeof parsed !== "string") return parsed; - try { - return JSON.parse(parsed); - } catch { - return parsed; - } - } catch { - return null; - } -} diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index aa13ab17e..e3ba558d1 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -21,6 +21,11 @@ import type { ObserverEvent, TranscriptItem, } from "./agentSessionTypes"; +import { + deriveLatestSessionId, + resolveRawRailLayout, + scopeByChannel, +} from "./agentSessionPanelLayout"; import { shorten } from "./agentSessionUtils"; import { useObserverEvents, useAgentTranscript } from "./useObserverEvents"; @@ -58,29 +63,21 @@ export function ManagedAgentSessionPanel({ // Filter transcript items by channelId (lightweight — items now carry channelId) const scopedTranscript = React.useMemo( - () => - channelId - ? transcript.filter((item) => item.channelId === channelId) - : transcript, + () => scopeByChannel(transcript, channelId), [channelId, transcript], ); // Filter raw events by channelId for the RawEventRail const scopedEvents = React.useMemo( - () => - channelId - ? events.filter((event) => event.channelId === channelId) - : events, + () => scopeByChannel(events, channelId), [channelId, events], ); // Derive latestSessionId from channel-scoped events - const latestSessionId = React.useMemo(() => { - for (let i = scopedEvents.length - 1; i >= 0; i--) { - if (scopedEvents[i].sessionId) return scopedEvents[i].sessionId; - } - return null; - }, [scopedEvents]); + const latestSessionId = React.useMemo( + () => deriveLatestSessionId(scopedEvents), + [scopedEvents], + ); return (
@@ -205,7 +204,7 @@ function SessionBody({ ) : (
- {showRaw && rawLayout === "responsive" ? ( - - ) : null} + {rawRail.mode === "side" ? : null}
)} diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs new file mode 100644 index 000000000..96af79d16 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + deriveLatestSessionId, + resolveRawRailLayout, + scopeByChannel, +} from "./agentSessionPanelLayout.ts"; + +// ---- scopeByChannel ---- + +const items = [ + { id: "a", channelId: "channel-1" }, + { id: "b", channelId: "channel-2" }, + { id: "c", channelId: "channel-1" }, +]; + +test("scopeByChannel returns the input unchanged when channelId is null", () => { + assert.equal(scopeByChannel(items, null), items); +}); + +test("scopeByChannel returns the input unchanged when channelId is undefined", () => { + assert.equal(scopeByChannel(items, undefined), items); +}); + +test("scopeByChannel filters items down to the requested channel", () => { + const scoped = scopeByChannel(items, "channel-1"); + assert.deepEqual( + scoped.map((item) => item.id), + ["a", "c"], + ); +}); + +test("scopeByChannel returns an empty array when no item matches", () => { + assert.deepEqual(scopeByChannel(items, "channel-99"), []); +}); + +// ---- deriveLatestSessionId ---- + +test("deriveLatestSessionId returns null for an empty list", () => { + assert.equal(deriveLatestSessionId([]), null); +}); + +test("deriveLatestSessionId returns the last event's sessionId", () => { + const events = [ + { seq: 1, sessionId: "sess-1" }, + { seq: 2, sessionId: "sess-2" }, + ]; + assert.equal(deriveLatestSessionId(events), "sess-2"); +}); + +test("deriveLatestSessionId skips trailing events without a sessionId", () => { + const events = [ + { seq: 1, sessionId: "sess-1" }, + { seq: 2, sessionId: null }, + { seq: 3, sessionId: undefined }, + ]; + assert.equal(deriveLatestSessionId(events), "sess-1"); +}); + +test("deriveLatestSessionId returns null when no event carries a sessionId", () => { + const events = [{ seq: 1, sessionId: null }, { seq: 2 }]; + assert.equal(deriveLatestSessionId(events), null); +}); + +// ---- resolveRawRailLayout (raw-ACP view toggle) ---- + +test("resolveRawRailLayout hides the rail when showRaw is off", () => { + assert.deepEqual(resolveRawRailLayout(false, "responsive"), { + mode: "hidden", + }); + assert.deepEqual(resolveRawRailLayout(false, "exclusive"), { + mode: "hidden", + }); +}); + +test("resolveRawRailLayout renders the rail exclusively when toggled on in exclusive layout", () => { + assert.deepEqual(resolveRawRailLayout(true, "exclusive"), { + mode: "exclusive", + }); +}); + +test("resolveRawRailLayout renders the rail beside the transcript in responsive layout", () => { + assert.deepEqual(resolveRawRailLayout(true, "responsive"), { mode: "side" }); +}); diff --git a/desktop/src/features/agents/ui/agentSessionPanelLayout.ts b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts new file mode 100644 index 000000000..e0aaeb203 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionPanelLayout.ts @@ -0,0 +1,48 @@ +import type { ObserverEvent } from "./agentSessionTypes"; + +/** + * Filter transcript items or raw observer events down to a single channel. + * A null `channelId` means "no scoping" — the input is returned as-is. + */ +export function scopeByChannel( + items: readonly T[], + channelId: string | null | undefined, +): T[] { + if (!channelId) return items as T[]; + return items.filter((item) => item.channelId === channelId); +} + +/** + * Derive the most recent session id from a list of observer events by + * scanning from the end. Returns null when no event carries a sessionId. + */ +export function deriveLatestSessionId( + events: readonly ObserverEvent[], +): string | null { + for (let i = events.length - 1; i >= 0; i--) { + const sessionId = events[i]?.sessionId; + if (sessionId) return sessionId; + } + return null; +} + +export type RawRailLayout = + | { mode: "hidden" } + | { mode: "exclusive" } + | { mode: "side" }; + +/** + * Decide how the raw-ACP event rail should be rendered relative to the + * transcript: + * - `hidden` — raw view is off + * - `exclusive` — raw rail replaces the transcript entirely + * - `side` — raw rail renders alongside the transcript (responsive) + */ +export function resolveRawRailLayout( + showRaw: boolean, + rawLayout: "responsive" | "exclusive", +): RawRailLayout { + if (!showRaw) return { mode: "hidden" }; + if (rawLayout === "exclusive") return { mode: "exclusive" }; + return { mode: "side" }; +} diff --git a/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs new file mode 100644 index 000000000..52b548ec6 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolItemHelpers.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + formatDurationMs, + getToolDurationDisplay, + isInlineImageData, + parseToolResultValue, +} from "./agentSessionUtils.ts"; + +// ---- isInlineImageData (dual-layer image-scheme security guard) ---- + +test("isInlineImageData accepts data:image/ URIs", () => { + const dataUri = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC"; + assert.equal(isInlineImageData(dataUri), true); +}); + +test("isInlineImageData rejects non-image data: schemes (no passthrough widening)", () => { + // A non-image data: URI must NOT be treated as a safe inline image — + // it has to fall through to the relay rewrite path. + assert.equal( + isInlineImageData( + "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", + ), + false, + ); + assert.equal(isInlineImageData("data:application/json;base64,e30="), false); +}); + +test("isInlineImageData rejects relay-relative and absolute media URLs", () => { + assert.equal(isInlineImageData("/media/abc123.png"), false); + assert.equal(isInlineImageData("https://relay.example/media/abc.png"), false); +}); + +// ---- formatDurationMs ---- + +test("formatDurationMs returns null for negative input", () => { + assert.equal(formatDurationMs(-1), null); +}); + +test("formatDurationMs renders sub-10s with one decimal", () => { + assert.equal(formatDurationMs(400), "0.4s"); + assert.equal(formatDurationMs(9900), "9.9s"); +}); + +test("formatDurationMs rounds 10s..60s to whole seconds", () => { + assert.equal(formatDurationMs(12300), "12s"); + assert.equal(formatDurationMs(59400), "59s"); +}); + +test("formatDurationMs renders minutes and seconds", () => { + assert.equal(formatDurationMs(90000), "1m 30s"); + assert.equal(formatDurationMs(120000), "2m"); +}); + +test("formatDurationMs carries a rounded 60s into the next minute", () => { + // 89.7s rounds the seconds component to 60, which must carry to 1m 30s + assert.equal(formatDurationMs(89700), "1m 30s"); +}); + +// ---- parseToolResultValue (JSON double-parse) ---- + +test("parseToolResultValue returns null for empty/whitespace", () => { + assert.equal(parseToolResultValue(""), null); + assert.equal(parseToolResultValue(" "), null); +}); + +test("parseToolResultValue parses a JSON object", () => { + assert.deepEqual(parseToolResultValue('{"duration_ms":123}'), { + duration_ms: 123, + }); +}); + +test("parseToolResultValue unwraps a double-encoded JSON string", () => { + // The result is a JSON string that itself contains JSON. + const doubleEncoded = JSON.stringify(JSON.stringify({ ok: true })); + assert.deepEqual(parseToolResultValue(doubleEncoded), { ok: true }); +}); + +test("parseToolResultValue returns the inner string when it is not JSON", () => { + const wrapped = JSON.stringify("plain text"); + assert.equal(parseToolResultValue(wrapped), "plain text"); +}); + +test("parseToolResultValue returns null for invalid JSON", () => { + assert.equal(parseToolResultValue("not json {"), null); +}); + +// ---- getToolDurationDisplay (fallback chain) ---- + +const startedAt = "2026-06-14T19:00:00.000Z"; +const completedAt = "2026-06-14T19:00:02.000Z"; + +test("getToolDurationDisplay prefers start/complete timestamps", () => { + assert.equal( + getToolDurationDisplay({ startedAt, completedAt, result: "" }), + "2.0s", + ); +}); + +test("getToolDurationDisplay falls back to duration_ms in the result payload", () => { + assert.equal( + getToolDurationDisplay({ + startedAt: null, + completedAt: null, + result: JSON.stringify({ duration_ms: 3500 }), + }), + "3.5s", + ); +}); + +test("getToolDurationDisplay falls back to elapsed_ms when duration_ms absent", () => { + assert.equal( + getToolDurationDisplay({ + result: JSON.stringify({ elapsed_ms: 65000 }), + }), + "1m 5s", + ); +}); + +test("getToolDurationDisplay returns null when no duration is available", () => { + assert.equal(getToolDurationDisplay({ result: "" }), null); + assert.equal( + getToolDurationDisplay({ result: JSON.stringify({ other: 1 }) }), + null, + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index 02ea90e2f..1513e1017 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -110,7 +110,7 @@ function classifyDeveloperToolName( if (base === "postcompact") return "post_compact_hook"; if (DEVELOPER_TOOL_BASES.has(base)) { - return base === "shell" ? "shell" : "dev_mcp"; + return "dev_mcp"; } if (normalized.includes("buzz_dev_mcp")) { diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts index d350ca299..fd2877f27 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts @@ -84,9 +84,6 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] { segments.push({ kind: "item", item }); continue; } - if (isSetupLifecycle(item)) { - continue; - } segments.push({ kind: "item", item }); } diff --git a/desktop/src/features/agents/ui/agentSessionUtils.ts b/desktop/src/features/agents/ui/agentSessionUtils.ts index 19d915ec8..39cec560e 100644 --- a/desktop/src/features/agents/ui/agentSessionUtils.ts +++ b/desktop/src/features/agents/ui/agentSessionUtils.ts @@ -64,6 +64,91 @@ export function asRecord(value: unknown): Record { : {}; } +/** + * True when a tool image source is an inline `data:image/` URI that should be + * rendered as-is. This is the dual-layer image-scheme guard: only the + * `data:image/` prefix is treated as a safe passthrough — every other scheme + * (including other `data:` subtypes) must be routed through the relay rewriter. + * Never widen this beyond `data:image/`. + */ +export function isInlineImageData(source: string): boolean { + return source.startsWith("data:image/"); +} + +function getToolNumber( + record: Record, + keys: string[], +): number | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + return null; +} + +/** Format a millisecond duration; negative input yields null. */ +export function formatDurationMs(ms: number): string | null { + if (ms < 0) return null; + const totalSeconds = ms / 1000; + if (totalSeconds < 60) { + return totalSeconds < 10 + ? `${totalSeconds.toFixed(1)}s` + : `${Math.round(totalSeconds)}s`; + } + let minutes = Math.floor(totalSeconds / 60); + let seconds = Math.round(totalSeconds % 60); + if (seconds === 60) { + minutes += 1; + seconds = 0; + } + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +} + +/** + * Parse a tool result string into a value. Handles the double-encoding case + * where a JSON string itself contains JSON. Returns null on empty or invalid + * input. + */ +export function parseToolResultValue(result: string): unknown { + const trimmed = result.trim(); + if (!trimmed) return null; + + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "string") return parsed; + try { + return JSON.parse(parsed); + } catch { + return parsed; + } + } catch { + return null; + } +} + +/** + * Resolve a tool's display duration. Prefers the start/complete timestamps, + * then falls back to `duration_ms`/`elapsed_ms` fields inside the parsed + * result payload. + */ +export function getToolDurationDisplay(item: { + startedAt?: string | null; + completedAt?: string | null; + result: string; +}): string | null { + if (item.startedAt && item.completedAt) { + return formatDuration(item.startedAt, item.completedAt); + } + + const resultRecord = asRecord(parseToolResultValue(item.result)); + const durationMs = + getToolNumber(resultRecord, ["duration_ms", "durationMs"]) ?? + getToolNumber(resultRecord, ["elapsed_ms", "elapsedMs"]); + return durationMs == null ? null : formatDurationMs(durationMs); +} + export function asString(value: unknown): string | null { return typeof value === "string" ? value : null; } diff --git a/desktop/src/features/agents/ui/rawEventRail.test.mjs b/desktop/src/features/agents/ui/rawEventRail.test.mjs new file mode 100644 index 000000000..7750fdac2 --- /dev/null +++ b/desktop/src/features/agents/ui/rawEventRail.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { describeRawEvent } from "./agentSessionTranscriptHelpers.ts"; + +function rawEvent(overrides = {}) { + return { + seq: 1, + kind: "acp", + sessionId: "sess-1", + channelId: "channel-1", + payload: {}, + ...overrides, + }; +} + +test("describeRawEvent surfaces the session/update sessionUpdate label", () => { + const event = rawEvent({ + payload: { + method: "session/update", + params: { update: { sessionUpdate: "agent_message_chunk" } }, + }, + }); + assert.equal(describeRawEvent(event), "agent_message_chunk"); +}); + +test("describeRawEvent falls back to the method when session/update lacks an update label", () => { + const event = rawEvent({ + payload: { method: "session/update", params: {} }, + }); + assert.equal(describeRawEvent(event), "session/update"); +}); + +test("describeRawEvent uses the method for non-session/update payloads", () => { + const event = rawEvent({ payload: { method: "session/prompt" } }); + assert.equal(describeRawEvent(event), "session/prompt"); +}); + +test("describeRawEvent falls back to the event kind when no method is present", () => { + const event = rawEvent({ kind: "acp_parse_error", payload: {} }); + assert.equal(describeRawEvent(event), "acp_parse_error"); +});