diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index 8960f9de..e3abc338 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -407,6 +407,9 @@ const en: Dict = { 'tx.result': 'Result', 'tx.errorLabel': 'Error', 'tx.denied': 'Denied', + 'tx.liveOutput': 'Live output', + 'tx.outputClipped': 'earlier output trimmed', + 'tx.imageUnavailable': 'Image unavailable', 'tx.thinking': 'Thinking…', 'tx.session': 'Session', 'tx.done': 'Done', @@ -2692,6 +2695,9 @@ const zh: Dict = { 'tx.result': '结果', 'tx.errorLabel': '错误', 'tx.denied': '已拒绝', + 'tx.liveOutput': '实时输出', + 'tx.outputClipped': '已省略较早的输出', + 'tx.imageUnavailable': '图片不可用', 'tx.thinking': '思考中…', 'tx.session': '会话', 'tx.done': '完成', diff --git a/desktop/src/styles/partials/05-transcript-boards.css b/desktop/src/styles/partials/05-transcript-boards.css index 9fe75467..bdbba970 100644 --- a/desktop/src/styles/partials/05-transcript-boards.css +++ b/desktop/src/styles/partials/05-transcript-boards.css @@ -1339,6 +1339,62 @@ border-radius: var(--radius-sm); background: var(--surface); } +/* An externalized image being fetched by sha, and one we could not fetch. + Both hold the row's height so the transcript doesn't jump when it lands. */ +.ev-image-loading { + display: block; + width: 220px; + aspect-ratio: 16/10; + border-radius: var(--radius-sm); + background: var(--surface-sunken, var(--bg)); + border: 1px dashed var(--border); +} +.ev-image-missing { + display: inline-flex; + align-items: center; + padding: var(--spacing-s8); + font-size: var(--font-size-caption); + color: var(--text-muted); + border: 1px dashed var(--border); + border-radius: var(--radius-sm); +} + +/* ---- R4 live command output ---- + E3 streams a running command's stdout/stderr as tool_call_update partials. + The block is always open (you watch a build, you don't unfold it) and scrolls + inside its own box so a long run can't push the composer off screen. */ +.ev-stream { + margin-top: var(--spacing-s4); +} +.ev-stream-head { + display: flex; + align-items: center; + gap: var(--spacing-s6); + font-size: var(--font-size-caption); + color: var(--text-muted); +} +.ev-stream-label { + font-weight: 500; +} +.ev-stream-clip { + font-style: italic; +} +/* The scroller is a flex column-reverse so it stays pinned to the newest line + as output arrives — the tail is what you're waiting on. It wraps a single + REAL child (never an anonymous text item, whose flex behaviour is the shaky + part of this pattern), and that child drops .ev-mono's own cap so there is + exactly one scrollbar. */ +.ev-stream-body { + max-height: 260px; + overflow-y: auto; + display: flex; + flex-direction: column-reverse; +} +.ev-stream-text { + margin: 0; + max-height: none; + overflow: visible; +} .digest pre { white-space: pre-wrap; word-break: break-word; diff --git a/desktop/src/surfaces/AgentTranscript.tsx b/desktop/src/surfaces/AgentTranscript.tsx index 98de8f46..95f1a227 100644 --- a/desktop/src/surfaces/AgentTranscript.tsx +++ b/desktop/src/surfaces/AgentTranscript.tsx @@ -480,7 +480,14 @@ export function AgentTranscript({ agentId, sessionId }: { agentId: string; sessi } if (ev.kind === 'tool_call') { const id = callToolId(ev.payload); - return ; + return ( + + ); } // Quote-into-composer only where the composer lives (live mode). return ; diff --git a/desktop/src/ui/AgentCompanion.tsx b/desktop/src/ui/AgentCompanion.tsx index 12bddd5c..2e9e78c2 100644 --- a/desktop/src/ui/AgentCompanion.tsx +++ b/desktop/src/ui/AgentCompanion.tsx @@ -24,6 +24,7 @@ import { InlineAttentionCards } from './ApprovalCards'; import { pendingAttentionFor } from './approvalRequest'; import { callToolId, EventCard, toFeedEvent } from './EventCard'; import { isHiddenInFeed } from './feedLens'; +import { toolCallUpdateParentId } from './toolGroups'; import { LocalAgentLauncher } from './LocalAgentLauncher'; // Cap per-mention file text so a large file can't blow the message context. @@ -262,14 +263,23 @@ export function AgentCompanion({ const feed = useMemo(() => events.map((e, i) => toFeedEvent(e, i)), [events]); - const { resultById, nameById, callIds } = useMemo(() => { + // NOTE: a hand-copy of AgentTranscript's `useToolMaps`. The duplication is + // why this one silently lacked `updateById` (so R4's streamed output had + // nowhere to come from here) — worth collapsing into one shared hook, but + // that refactor is deliberately not folded into this wedge. + const { resultById, updateById, nameById, callIds } = useMemo(() => { const resultById = new Map(); + const updateById = new Map(); const nameById = new Map(); const callIds = new Set(); for (const ev of feed) { if (ev.kind === 'tool_result') { const id = str(ev.payload, 'tool_use_id'); if (id !== undefined) resultById.set(id, ev.payload); + } else if (ev.kind === 'tool_call_update') { + // Latest update wins, matching useToolMaps. + const id = toolCallUpdateParentId(ev.payload); + if (id !== undefined) updateById.set(id, ev.payload); } else if (ev.kind === 'tool_call') { const id = callToolId(ev.payload); if (id !== undefined) { @@ -279,7 +289,7 @@ export function AgentCompanion({ } } } - return { resultById, nameById, callIds }; + return { resultById, updateById, nameById, callIds }; }, [feed]); // Visible feed: hide noise + fold tool_results that a tool_call already shows. @@ -434,13 +444,19 @@ export function AgentCompanion({
{visible.map((ev) => { if (ev.kind === 'tool_call') { + // The two props were swapped here: a tool_call needs its `result` + // (ToolCallBody folds it in) and a tool_result needs `callName` (to + // borrow its tool's name back). Passing each the other's prop meant + // the Companion never folded a result into its call card and + // labelled every standalone result "Result". const id = callToolId(ev.payload); return ( ); } @@ -451,7 +467,7 @@ export function AgentCompanion({ key={ev.id} ev={ev} agentId={agentId} - result={id !== undefined ? resultById.get(id) : undefined} + callName={id !== undefined ? nameById.get(id) : undefined} /> ); } diff --git a/desktop/src/ui/EventCard.tsx b/desktop/src/ui/EventCard.tsx index 5ca8de70..cc3db26b 100644 --- a/desktop/src/ui/EventCard.tsx +++ b/desktop/src/ui/EventCard.tsx @@ -7,6 +7,8 @@ import { useT, type TLookup } from '../i18n'; import { arr, bool, num, obj, str, type Entity } from '../hub/types'; import { callToolId } from './toolGroups'; import { contextDivider, fmtCost, fmtDuration, turnFooter, type ContextDivider } from './turnMarkers.ts'; +import { EventImage, EventImages, StreamedOutput } from './EventMedia'; +import { imageRefsOf, mediaRefFrom, resultTextOf, streamedOutputOf } from './toolMedia'; // Re-exported from its new home in toolGroups.ts (the P1 tool-lineage // substrate) so existing importers keep working. @@ -239,8 +241,29 @@ function ToolResultBody({ result }: { result: Entity }): JSX.Element { const t = useT(); const isErr = bool(result, 'is_error') === true; const denied = bool(result, 'denied') === true; - const content = str(result, 'content') ?? jsonText(result['content']); + // A tool that returns a picture (claude reading a PNG, or any bridge tool + // behind E4's relay passthrough) sends image BLOCKS, and the drivers forward + // the content verbatim. Painting them beats the old behaviour, which + // jsonText'd the array and printed a screen of base64 at the director. + const media = imageRefsOf(result['content']); const label = denied ? t('tx.denied') : isErr ? t('tx.errorLabel') : t('tx.result'); + if (media.length > 0) { + // The image IS the result. Any text blocks that came with it still show; + // the raw content deliberately does NOT, because "raw" here is megabytes of + // base64 and
keeps its children in the DOM even when closed. + const sidecar = resultTextOf(result['content']); + return ( +
+ + {sidecar !== '' && ( + +
{sidecar}
+
+ )} +
+ ); + } + const content = str(result, 'content') ?? jsonText(result['content']); return (
@@ -254,13 +277,20 @@ function ToolResultBody({ result }: { result: Entity }): JSX.Element { /// Arguments disclosure, and the folded-in tool_result. Exported so the P1 /// tool-group card (ToolGroupCard.tsx) reuses it verbatim as a row's lazy /// detail. -export function ToolCallBody({ p, result }: { p: Entity; result?: Entity }): JSX.Element { +export function ToolCallBody({ p, result, update }: { p: Entity; result?: Entity; update?: Entity }): JSX.Element { const t = useT(); const name = str(p, 'name') ?? 'tool'; const input = p['input']; const hasInput = input !== undefined && input !== null && input !== ''; const meta = toolMeta(name, input); const errored = result !== undefined && bool(result, 'is_error') === true; + // E3 streams a running command's output as tool_call_update partials, each + // carrying the whole buffer so far; useToolMaps has already folded them + // latest-wins onto this call. Once the tool_result lands it carries the same + // bytes (codex's `aggregatedOutput`, which the E3 probe measured as identical + // to the reassembled deltas), so the live block stands down rather than + // printing the output twice. + const streamed = result === undefined ? streamedOutputOf(update) : ''; return (
@@ -278,6 +308,7 @@ export function ToolCallBody({ p, result }: { p: Entity; result?: Entity }): JSX
{jsonText(input)}
)} + {result !== undefined && }
); @@ -385,29 +416,36 @@ function InputTextBody({ p }: { p: Entity }): JSX.Element { function InputImages({ p }: { p: Entity }): JSX.Element | null { const images = arr(p, 'images'); if (images.length === 0) return null; + // A `blob:sha256/` ref here is not hypothetical and not rare: the hub + // externalizes every payload string leaf over 64 KiB on ingest + // (payload_externalize.go), and any real screenshot's base64 clears that. It + // used to be skipped, so pasted images simply vanished from the transcript at + // the size where they matter most; EventImage resolves them by sha. + const media = images.flatMap((img) => { + const e = (img !== null && typeof img === 'object' ? img : {}) as Entity; + const ref = mediaRefFrom(str(e, 'mime_type'), str(e, 'data')); + // Keep the per-image filename as alt text — it is the only label a + // screen reader (or a broken image) has for a director's attachment. + return ref === undefined ? [] : [{ ref, alt: str(e, 'filename') ?? 'attachment' }]; + }); + if (media.length === 0) return null; return (
- {images.map((img, i) => { - const e = (img !== null && typeof img === 'object' ? img : {}) as Entity; - const mime = str(e, 'mime_type') ?? 'image/png'; - const data = str(e, 'data') ?? ''; - // A blob:sha256/ ref would mean a future hub externalized the payload; - // rendering that needs the blob resolver, so skip rather than break. - if (data === '' || data.startsWith('blob:')) return null; - return ( - {str(e, - ); - })} + {media.map((m, i) => ( + + ))}
); } -function bodyFor(ev: FeedEvent, t: TLookup, result?: Entity, callName?: string, agentId?: string): ReactNode { +function bodyFor( + ev: FeedEvent, + t: TLookup, + result?: Entity, + callName?: string, + agentId?: string, + update?: Entity, +): ReactNode { const p = ev.payload; switch (ev.kind) { case 'text': { @@ -439,7 +477,7 @@ function bodyFor(ev: FeedEvent, t: TLookup, result?: Entity, callName?: string, case 'attention_request': return ; case 'tool_call': - return ; + return ; case 'tool_call_update': { // Standalone update — only reachable when the parent is gated or missing // (feedLens folds the rest into the parent card). Mobile parity @@ -447,17 +485,13 @@ function bodyFor(ev: FeedEvent, t: TLookup, result?: Entity, callName?: string, // of the ACP content array as a preview. const title = str(p, 'title') ?? str(p, 'name') ?? 'tool'; const status = str(p, 'status'); - let preview: string | undefined; - for (const b of arr(p, 'content')) { - if (b === null || typeof b !== 'object') continue; - const blk = b as Entity; - if (str(blk, 'type') !== 'content') continue; - const inner = obj(blk, 'content'); - if (inner !== undefined && str(inner, 'type') === 'text') { - preview = str(inner, 'text'); - break; - } - } + // This card is the ONLY place a parentless update's content is visible, + // so it shows the whole streamed output rather than mobile's one-line + // preview — for a command whose parent tool_call never arrived, the first + // line is rarely the one you need. Images render as images (an ACP update + // can carry an image block just as a tool_result can). + const streamed = streamedOutputOf(p); + const media = imageRefsOf(p['content']); return (
@@ -465,7 +499,8 @@ function bodyFor(ev: FeedEvent, t: TLookup, result?: Entity, callName?: string, {title} {status !== undefined && · {status}}
- {preview !== undefined && preview !== '' &&
{firstLine(preview)}
} + +
); } @@ -648,6 +683,7 @@ export const EventCard = memo(function EventCard({ callName, agentId, onQuote, + update, }: { ev: FeedEvent; result?: Entity; @@ -659,6 +695,9 @@ export const EventCard = memo(function EventCard({ /// Quote this message into the composer (assistant text only). Omitted where /// there is no composer to quote into. onQuote?: (text: string) => void; + /// The latest `tool_call_update` folded onto this `tool_call` (useToolMaps' + /// updateById). Carries E3's streamed command output while the tool runs. + update?: Entity; }): JSX.Element { const t = useT(); // #332: three tones (user / error / neutral); boxed only where action lives, @@ -678,7 +717,7 @@ export const EventCard = memo(function EventCard({ const text = messageText(ev); return (
-
{bodyFor(ev, t, result, callName, agentId)}
+
{bodyFor(ev, t, result, callName, agentId, update)}
{text !== undefined && (ev.ts !== undefined || text.trim() !== '') && (
{ev.ts !== undefined && } diff --git a/desktop/src/ui/EventMedia.tsx b/desktop/src/ui/EventMedia.tsx new file mode 100644 index 00000000..5d420b29 --- /dev/null +++ b/desktop/src/ui/EventMedia.tsx @@ -0,0 +1,99 @@ +/// Transcript media + live-output views (vision-parity R4). The extraction is +/// pure (toolMedia.ts); this file only paints, and owns the one piece that +/// cannot be pure — fetching an externalized blob. +import { useQuery } from '@tanstack/react-query'; +import { useSession } from '../state/session'; +import { useT } from '../i18n'; +import { tailLines, type MediaRef } from './toolMedia'; + +/// How many trailing lines of a running command's output we keep in the DOM. +/// E3 tail-caps the wire payload at 32 KiB, but an ACP engine or the +/// desktop-local driver is uncapped, so the row bounds itself too. +const MAX_OUTPUT_LINES = 400; + +/// Decode a blob body that holds base64 TEXT back into that text. +/// +/// This is the subtlety of an externalized payload leaf. `payload_externalize.go` +/// replaces an oversized string leaf with a blob ref and stores `[]byte(leaf)` — +/// so for an image the blob's bytes ARE the base64 characters, not the decoded +/// picture. `getBytes` then base64-encodes those bytes for transport, leaving us +/// with base64(base64(image)). One `atob` unwraps the transport layer and hands +/// back the original base64 the `` wants. +/// +/// This is why the blob path cannot reuse `getBlobDataUrl`: that helper is built +/// for artifact blobs (raw bytes, real mime) and would both double-encode this +/// body and label it `application/octet-stream`. +function unwrapBase64Text(transportB64: string): string | undefined { + try { + const s = atob(transportB64); + return s === '' ? undefined : s; + } catch { + return undefined; + } +} + +/// One agent-produced image. An inline ref paints immediately; a blob ref is +/// fetched by sha. The MIME always comes from the event block, never from the +/// blob record — the hub stores externalized leaves as application/octet-stream, +/// which no browser will paint as an image. +export function EventImage({ media, alt }: { media: MediaRef; alt: string }): JSX.Element { + const t = useT(); + const client = useSession((s) => s.client); + const sha = media.source === 'blob' ? media.sha : ''; + const blobQ = useQuery({ + queryKey: ['event-media', sha], + enabled: sha !== '' && client !== null, + staleTime: 5 * 60_000, + queryFn: () => client!.getBlobBytes(sha), + }); + + if (media.source === 'inline') { + return {alt}; + } + // A hub-less (desktop-local) session has no client to fetch with. Local + // drivers never externalize, so this is unreachable in practice — but say so + // rather than render a broken image icon. + if (client === null || blobQ.isError) { + return {t('tx.imageUnavailable')}; + } + const body = blobQ.data === undefined ? undefined : unwrapBase64Text(blobQ.data.base64); + if (body === undefined) { + return ; + } + return {alt}; +} + +/// A row of agent-produced images. Renders nothing when there are none, so +/// callers can drop it in unconditionally. +export function EventImages({ media, alt }: { media: MediaRef[]; alt: string }): JSX.Element | null { + if (media.length === 0) return null; + return ( +
+ {media.map((m, i) => ( + + ))} +
+ ); +} + +/// A running command's output, streamed by E3 and folded latest-wins into the +/// parent tool row. Scroll-capped rather than clamped: a running build's output +/// is something you watch, so the block is always open and pinned to its tail +/// (kimi-web's output block), not hidden behind a "show more". +export function StreamedOutput({ text }: { text: string }): JSX.Element | null { + const t = useT(); + if (text === '') return null; + const { text: shown, clipped } = tailLines(text, MAX_OUTPUT_LINES); + return ( +
+
+
+
+
{shown}
+
+
+ ); +} diff --git a/desktop/src/ui/ToolGroupCard.tsx b/desktop/src/ui/ToolGroupCard.tsx index 015559b4..875fff48 100644 --- a/desktop/src/ui/ToolGroupCard.tsx +++ b/desktop/src/ui/ToolGroupCard.tsx @@ -68,7 +68,7 @@ function GroupRow({ {detailOpen && (
- +
)}
diff --git a/desktop/src/ui/toolMedia.test.ts b/desktop/src/ui/toolMedia.test.ts new file mode 100644 index 00000000..4a0eed4a --- /dev/null +++ b/desktop/src/ui/toolMedia.test.ts @@ -0,0 +1,166 @@ +/// R4 tool-row media + live-output extraction checks. The image fixtures are +/// the shapes our producers actually emit, not invented ones: +/// - the Anthropic block is a verbatim capture from `claude --print +/// --output-format stream-json` reading a PNG (tool_result.content is a +/// LIST, and is_error is absent rather than false); +/// - the MCP block matches driver_acp.go:1752 and the desktop bridge results +/// E4 forwards unwrapped (hub/internal/server/mcp_browser_bridge_test.go). +/// The frontend package has no CI test runner; run locally with +/// `node --test src/ui/toolMedia.test.ts` from `desktop/`. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BLOB_REF_PREFIX, imageRefsOf, mediaRefFrom, resultTextOf, streamedOutputOf, tailLines } from './toolMedia.ts'; + +// Captured from claude-code's own stream-json output (2x2 red PNG). +const REAL_PNG = + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg=='; + +const anthropicBlock = { type: 'image', source: { type: 'base64', data: REAL_PNG, media_type: 'image/png' } }; +const mcpBlock = { type: 'image', data: REAL_PNG, mimeType: 'image/png' }; + +test('the measured claude tool_result image block paints inline', () => { + assert.deepEqual(imageRefsOf([anthropicBlock]), [{ source: 'inline', mime: 'image/png', data: REAL_PNG }]); +}); + +test('the MCP/ACP dialect paints inline too', () => { + assert.deepEqual(imageRefsOf([mcpBlock]), [{ source: 'inline', mime: 'image/png', data: REAL_PNG }]); +}); + +test('an ACP content wrapper is unwrapped one level', () => { + assert.deepEqual(imageRefsOf([{ type: 'content', content: mcpBlock }]), [ + { source: 'inline', mime: 'image/png', data: REAL_PNG }, + ]); +}); + +test('an externalized image becomes a blob ref, in either dialect', () => { + const sha = 'a'.repeat(64); + const ref = `${BLOB_REF_PREFIX}${sha}`; + assert.deepEqual(imageRefsOf([{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: ref } }]), [ + { source: 'blob', mime: 'image/png', sha }, + ]); + assert.deepEqual(imageRefsOf([{ type: 'image', mimeType: 'image/jpeg', data: ref }]), [ + { source: 'blob', mime: 'image/jpeg', sha }, + ]); +}); + +test('the mime rides the BLOCK, not the blob', () => { + // The hub stores an externalized leaf as application/octet-stream, so the + // block's own media_type is the only surviving statement of what the bytes + // are. If this ever regressed to the blob's mime the would paint + // nothing. + const refs = imageRefsOf([ + { type: 'image', source: { type: 'base64', media_type: 'image/svg+xml', data: `${BLOB_REF_PREFIX}${'b'.repeat(64)}` } }, + ]); + assert.equal(refs[0]?.mime, 'image/svg+xml'); +}); + +test('shapes that cannot be painted are skipped, not guessed at', () => { + // A plain-string content (the common tool_result) has no blocks at all. + assert.deepEqual(imageRefsOf('file contents'), []); + assert.deepEqual(imageRefsOf(undefined), []); + assert.deepEqual(imageRefsOf([]), []); + // Text blocks are not images. + assert.deepEqual(imageRefsOf([{ type: 'text', text: 'hi' }]), []); + // A url source would make the renderer fetch an agent-chosen host. + assert.deepEqual(imageRefsOf([{ type: 'image', source: { type: 'url', url: 'https://evil.example/x.png' } }]), []); + // ...and it must be the source-TYPE check that rejects it, not the happy + // accident that a url block carries no `data`. A block claiming both is the + // only input that tells those two guards apart (a mutation that dropped the + // type check survived the case above). + assert.deepEqual( + imageRefsOf([{ type: 'image', source: { type: 'url', url: 'https://evil.example/x.png', data: REAL_PNG, media_type: 'image/png' } }]), + [], + ); + // A non-image mime must not reach an . + assert.deepEqual(imageRefsOf([{ type: 'image', mimeType: 'text/html', data: 'PHNjcmlwdD4=' }]), []); + // Empty data is not a picture. + assert.deepEqual(imageRefsOf([{ type: 'image', mimeType: 'image/png', data: '' }]), []); + // A bare ref prefix with no sha resolves to nothing fetchable. + assert.deepEqual(imageRefsOf([{ type: 'image', mimeType: 'image/png', data: BLOB_REF_PREFIX }]), []); + // Junk in the array does not throw or poison the good blocks. + assert.deepEqual(imageRefsOf([null, 'x', 42, mcpBlock]), [{ source: 'inline', mime: 'image/png', data: REAL_PNG }]); +}); + +test('a missing mime defaults to png rather than dropping the image', () => { + assert.deepEqual(mediaRefFrom(undefined, REAL_PNG), { source: 'inline', mime: 'image/png', data: REAL_PNG }); + assert.equal(mediaRefFrom('image/png', undefined), undefined); + assert.equal(mediaRefFrom('application/pdf', REAL_PNG), undefined); +}); + +test('multiple image blocks all render, in order', () => { + const refs = imageRefsOf([anthropicBlock, { type: 'text', text: 'between' }, mcpBlock]); + assert.equal(refs.length, 2); +}); + +// --- live output ----------------------------------------------------------- + +test("E3's payload shape yields the whole cumulative buffer", () => { + // Byte-identical to what driver_appserver.go flushOutputStream posts. + const update = { + toolCallId: 'item_1', + content: [{ type: 'content', content: { type: 'text', text: 'row 1\nrow 2\n' } }], + partial: true, + }; + assert.equal(streamedOutputOf(update), 'row 1\nrow 2\n'); +}); + +test('text blocks join with no separator (a byte stream, not paragraphs)', () => { + const update = { + content: [ + { type: 'content', content: { type: 'text', text: 'abc' } }, + { type: 'content', content: { type: 'text', text: 'def\n' } }, + ], + }; + assert.equal(streamedOutputOf(update), 'abcdef\n'); +}); + +test('an update with no text content yields empty string', () => { + assert.equal(streamedOutputOf(undefined), ''); + assert.equal(streamedOutputOf({}), ''); + assert.equal(streamedOutputOf({ content: 'not an array' }), ''); + assert.equal(streamedOutputOf({ content: [] }), ''); + assert.equal(streamedOutputOf({ content: [{ type: 'content', content: mcpBlock }] }), ''); + // A terminal ACP update carrying only a status must not print as output. + assert.equal(streamedOutputOf({ toolCallId: 'x', status: 'completed' }), ''); +}); + +test('a bare text block (no ACP wrapper) is still read', () => { + assert.equal(streamedOutputOf({ content: [{ type: 'text', text: 'bare' }] }), 'bare'); +}); + +// --- result text ----------------------------------------------------------- + +test('resultTextOf passes a plain string content straight through', () => { + assert.equal(resultTextOf('file contents'), 'file contents'); + assert.equal(resultTextOf(undefined), ''); + assert.equal(resultTextOf(42), ''); +}); + +test('resultTextOf joins discrete text blocks with newlines', () => { + assert.equal(resultTextOf([{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }]), 'first\nsecond'); +}); + +test('resultTextOf never returns image bytes', () => { + // This is the point of the function: an image-bearing result must not put a + // megabyte of base64 into the DOM, even inside a collapsed
. + const out = resultTextOf([anthropicBlock, { type: 'text', text: 'the screenshot' }]); + assert.equal(out, 'the screenshot'); + assert.ok(!out.includes(REAL_PNG)); + assert.equal(resultTextOf([anthropicBlock]), ''); +}); + +// --- tail cap -------------------------------------------------------------- + +test('tailLines keeps the newest lines and admits the clip', () => { + const text = Array.from({ length: 10 }, (_, i) => `line ${String(i)}`).join('\n'); + const out = tailLines(text, 3); + assert.equal(out.text, 'line 7\nline 8\nline 9'); + assert.equal(out.clipped, true); +}); + +test('tailLines leaves short output exactly alone', () => { + assert.deepEqual(tailLines('a\nb', 3), { text: 'a\nb', clipped: false }); + assert.deepEqual(tailLines('a\nb\nc', 3), { text: 'a\nb\nc', clipped: false }); + assert.deepEqual(tailLines('', 3), { text: '', clipped: false }); + assert.deepEqual(tailLines('a\nb', 0), { text: 'a\nb', clipped: false }); +}); diff --git a/desktop/src/ui/toolMedia.ts b/desktop/src/ui/toolMedia.ts new file mode 100644 index 00000000..e4527828 --- /dev/null +++ b/desktop/src/ui/toolMedia.ts @@ -0,0 +1,160 @@ +/// Tool-row media + live-output extraction (vision-parity R4). Pure and +/// import-free at runtime (the Entity import is type-only) so it runs under +/// `node --test` like streamingPartials.ts and the other src leaf modules. +/// +/// Two jobs, both feeding the tool row: +/// +/// 1. **Live output.** E3 (hub `driver_appserver.go`) buffers codex's +/// `item/commandExecution/outputDelta` and re-posts it as a +/// `tool_call_update` carrying the ACP payload verbatim — +/// `{toolCallId, content:[{type:"content", content:{type:"text", text}}], +/// partial:true}`, where `text` is the whole buffer so far (not a delta), +/// tail-capped at 32 KiB. `streamedOutputOf` pulls that text back out. +/// +/// 2. **Agent-produced images.** A `tool_result`'s `content` is whatever the +/// engine sent, forwarded verbatim by the drivers, so it may be a string OR +/// an array of content blocks — and an image block arrives in one of two +/// dialects (see `imageRefsOf`). +/// +/// Neither is a `streamingPartials` concern: that module folds `text`/`thought`/ +/// `plan` chains keyed on `message_id`, and a `tool_call_update` carries neither +/// (its id field is `toolCallId`). Update-to-parent folding is already done by +/// `useToolMaps`' `updateById` map, latest-wins. +import type { Entity } from '../hub/types'; + +/// The content-addressed ref scheme the hub swaps in for oversized payload +/// leaves (`hub/internal/server/payload_externalize.go`). +export const BLOB_REF_PREFIX = 'blob:sha256/'; + +/// A normalized, paintable image reference. +/// +/// `inline` carries base64 bytes that came down with the event. `blob` is the +/// externalized case: the hub replaces **every** JSON string leaf over 64 KiB +/// with a `blob:sha256/` ref on agent-event ingest +/// (`handlers_agent_events.go:118`), and a real screenshot's base64 is always +/// over that. So `blob` is the NORMAL path for agent-produced images, not an +/// exotic one — the ref carries no bytes and must be fetched. +export type MediaRef = + | { source: 'inline'; mime: string; data: string } + | { source: 'blob'; mime: string; sha: string }; + +function isRecord(v: unknown): v is Entity { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +function strOf(o: Entity, k: string): string | undefined { + const v = o[k]; + return typeof v === 'string' ? v : undefined; +} + +/// Normalize one `(mime, data)` pair into a `MediaRef`, routing an externalized +/// `blob:sha256/` payload to the blob branch. Returns undefined for an +/// empty payload or a non-image mime — a `` that can't paint is worse than +/// no card, and the mime here is attacker-adjacent data (it rides an agent +/// event), so this is an allow-list rather than a pass-through. +export function mediaRefFrom(mime: string | undefined, data: string | undefined): MediaRef | undefined { + if (data === undefined || data === '') return undefined; + // A block that declares no mime is still an image block by position (the + // caller only reaches here for type:"image"); mirror the existing + // InputImages default rather than dropping the image. + const m = mime === undefined || mime === '' ? 'image/png' : mime; + if (!m.startsWith('image/')) return undefined; + if (data.startsWith(BLOB_REF_PREFIX)) { + const sha = data.slice(BLOB_REF_PREFIX.length); + return sha === '' ? undefined : { source: 'blob', mime: m, sha }; + } + return { source: 'inline', mime: m, data }; +} + +/// Pull paintable image refs out of a `tool_result` / `tool_call_update` +/// content value. Tolerates the three shapes our producers actually emit: +/// +/// - **Anthropic** (claude M2, `driver_stdio.go:446` forwards claude's +/// stream-json `tool_result` block verbatim — measured against +/// claude-code's own output): `{type:"image", source:{type:"base64", +/// media_type, data}}`. +/// - **MCP / ACP** (`driver_acp.go:1752`, and the desktop bridge tools E4 now +/// forwards unwrapped): `{type:"image", mimeType, data}`. +/// - **ACP ToolCallContent wrapper**: `{type:"content", content:}` — +/// unwrapped one level, which is also the shape E3's live output rides. +/// +/// A `content` that is a plain string (the common tool_result) yields nothing. +/// `source:{type:"url"}` is deliberately NOT painted: it would make the +/// renderer fetch an arbitrary host chosen by agent-controlled data. +export function imageRefsOf(content: unknown): MediaRef[] { + if (!Array.isArray(content)) return []; + const out: MediaRef[] = []; + for (const raw of content) { + if (!isRecord(raw)) continue; + // ACP wraps the real block one level down. + const block = strOf(raw, 'type') === 'content' && isRecord(raw['content']) ? raw['content'] : raw; + if (strOf(block, 'type') !== 'image') continue; + const src = block['source']; + const ref = isRecord(src) + ? // Anthropic dialect. Only base64 sources carry bytes we can paint. + strOf(src, 'type') === 'base64' + ? mediaRefFrom(strOf(src, 'media_type'), strOf(src, 'data')) + : undefined + : // MCP/ACP dialect — `mimeType` + `data` sit on the block itself. + mediaRefFrom(strOf(block, 'mimeType') ?? strOf(block, 'mime_type'), strOf(block, 'data')); + if (ref !== undefined) out.push(ref); + } + return out; +} + +/// The human-readable text of a tool result's content. +/// +/// A plain string is the content itself. A block array yields its text blocks +/// joined by newlines — these are discrete blocks, unlike the chunked byte +/// stream `streamedOutputOf` reassembles, so they get a separator. Image blocks +/// contribute nothing: their bytes belong in an ``, and dumping a +/// megabyte of base64 into the DOM is what this replaces. +export function resultTextOf(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + const parts: string[] = []; + for (const raw of content) { + if (!isRecord(raw)) continue; + const block = strOf(raw, 'type') === 'content' && isRecord(raw['content']) ? raw['content'] : raw; + if (strOf(block, 'type') !== 'text') continue; + const s = strOf(block, 'text'); + if (s !== undefined && s !== '') parts.push(s); + } + return parts.join('\n'); +} + +/// The cumulative streamed output carried by a `tool_call_update`, or '' when +/// it carries none. +/// +/// Text blocks are joined with no separator: the payload is a byte stream that +/// was chunked, not a list of paragraphs, so a separator would inject blank +/// lines into a command's output. In practice E3 emits exactly one block +/// holding the whole buffer, so this only decides the shape of a case no +/// current producer emits. +export function streamedOutputOf(update: Entity | undefined): string { + if (update === undefined) return ''; + const content = update['content']; + if (!Array.isArray(content)) return ''; + let out = ''; + for (const raw of content) { + if (!isRecord(raw)) continue; + const block = strOf(raw, 'type') === 'content' && isRecord(raw['content']) ? raw['content'] : raw; + if (strOf(block, 'type') !== 'text') continue; + out += strOf(block, 'text') ?? ''; + } + return out; +} + +/// The last [max] lines of [text], plus whether anything was dropped. +/// +/// E3 already tail-caps at 32 KiB, but nothing caps an ACP engine or the +/// desktop-local driver, so the renderer keeps its own bound. The TAIL is kept +/// (matching the producer's own trim) because a running command's newest output +/// is the interesting end. `clipped` exists so the row can SAY it dropped +/// something — a silently truncated log reads as a complete one. +export function tailLines(text: string, max: number): { text: string; clipped: boolean } { + if (text === '' || max <= 0) return { text, clipped: false }; + const lines = text.split('\n'); + if (lines.length <= max) return { text, clipped: false }; + return { text: lines.slice(lines.length - max).join('\n'), clipped: true }; +} diff --git a/docs/changelog-desktop.md b/docs/changelog-desktop.md index 31cde18a..4afacf02 100644 --- a/docs/changelog-desktop.md +++ b/docs/changelog-desktop.md @@ -99,6 +99,27 @@ This complements: the same UI-sharing toggle as its siblings, and deliberately not audited: it reads nothing about the user. (coworking C2 + C3) +- **A running command's output now appears while it runs, and pictures render + as pictures.** Two halves of the same gap — the transcript could describe + what a tool did but never show it. A long build or test run used to sit as a + silent spinner until it exited; its output now streams into the tool row, + scroll-capped and pinned to the newest line, and stands down when the + finished result arrives carrying the same bytes. And an image a tool returns + — claude reading a PNG, or any bridge tool behind the hub relay — now paints + inline instead of printing a screen of base64 at you. Both dialects our + engines actually emit are read (claude's `source:{type:"base64"}` and + MCP/ACP's `mimeType`+`data`), and a `url` source is deliberately not painted: + it would make the renderer fetch a host the agent chose. (vision-parity R4, + consuming E3 + E4) + + **Large images stopped disappearing.** The hub replaces any payload field + over 64 KiB with a content-addressed reference on ingest, which is every real + screenshot — and the transcript skipped those references rather than + resolving them, so an image vanished at exactly the size where it mattered. + They are now fetched by hash. The MIME comes from the event, not the blob + record, because the hub stores externalized bytes as + `application/octet-stream` and no browser will paint that as an image. + ### Changed - **The UI-sharing consent text now lists all eight gated tools.** It had said "four things" since the first Author lane and had never been updated for @@ -116,6 +137,13 @@ This complements: already render as a user card. - The `2026.805.1022` entry below listed `author_guide` among the verbs that shipped in it. It did not exist until now; the other three did. +- **In the Companion, a tool call never showed its result.** The two props were + handed to the wrong branches — a `tool_call` card was given the tool *name* it + had no use for while its result went to the `tool_result` card, which wanted + the name. So the Companion's call cards folded in nothing and every + standalone result was labelled just "Result". The full transcript surface was + always correct; only the dock was wired backwards. Found while threading the + streamed-output prop through the same call sites. --- diff --git a/docs/plans/desktop-companion-vision-parity.md b/docs/plans/desktop-companion-vision-parity.md index 2eeac926..8d546dd3 100644 --- a/docs/plans/desktop-companion-vision-parity.md +++ b/docs/plans/desktop-companion-vision-parity.md @@ -8,7 +8,8 @@ > 2026-08-14 — on-disk log + restart rebind) and **L3c** (session > catalog + loopback WS, split out of L3b). **E3 + E4 shipped > 2026-08-16** (streaming command output; relay result passthrough). -> Remaining: R4; L3c is deferrable +> **R4 shipped 2026-08-16** (live output + agent-produced media) — +> **W3 complete**. Remaining: L3c only, and it is deferrable > **Audience:** principal · contributors · maintainers > **Last verified vs code:** 2026.730.1231-alpha (`cea267fa`) — every > anchor below re-verified against that tip by the authoring audit @@ -741,13 +742,48 @@ Audit ground truth: claude M2 = `driver_stdio.go`, codex M2 = is desktop-only and a follow-up if the director wants it — mobile draws them as ordinary cards today. The producer fix benefits both clients immediately. -- **R4 — live output + agent-produced media.** Render E3's cumulative - `tool_call_update` output inside the running tool row (expandable - while running, scroll-capped like kimi-web's 50-line block), folding - by the `streamingPartials.ts` chain mechanism. Render agent-produced - images: `tool_result` MCP image blocks and `termipod-att://` refs - paint inline (the `Markdown.tsx` resolver exists); `blob:` refs get - the blob resolver instead of today's skip (asymmetry #4). +- **R4 — live output + agent-produced media.** *(shipped 2026-08-16)* + Render E3's cumulative `tool_call_update` output inside the running + tool row (scroll-capped like kimi-web's 50-line block); render + agent-produced images inline; resolve `blob:` refs instead of + skipping them (asymmetry #4). + + **Three of this line's five specifics were wrong** — recorded because + the corrections are the wedge: + + 1. *"folding by the `streamingPartials.ts` chain mechanism"* — no. + That module lives in `ui/`, not `state/`, its `FOLD_KINDS` is + `text|thought|plan`, and its chains key on **`message_id`**. A + `tool_call_update` carries neither: its id field is `toolCallId`. + Nothing needed folding — `useToolMaps`' `updateById` already + joins updates to their parent call, latest-wins. The real gap was + downstream: `ToolCallBody` took only `(p, result)`, so the folded + update had no way in, and `isToolCallUpdateHidden` suppresses the + standalone card whenever a visible parent exists. E3's output was + reaching the client and being dropped by the renderer. + 2. *"MCP image blocks"* — only half. claude M2 forwards claude's own + `tool_result` content verbatim (`driver_stdio.go:446`), which is + the **Anthropic** dialect `{type:"image", source:{type:"base64", + media_type, data}}`. Measured, not assumed: `claude --print + --output-format stream-json` reading a PNG returns exactly that, + as a LIST, with `is_error` absent rather than false. The MCP + dialect (`mimeType`+`data`) is the ACP/bridge shape. Both render. + 3. *"`termipod-att://` refs paint inline"* — a category error. That + scheme is Tauri-only note-attachment plumbing for the Read + surface (`state/attachments.ts:200`); no agent event has ever + carried one. Out of scope, nothing to do. + + And one framing correction: *"a `blob:` ref would mean a future hub"* + (the old code comment) is already false. `payload_externalize.go` + swaps **every** string leaf over 64 KiB for a `blob:sha256/` ref on + agent-event ingest (`handlers_agent_events.go:118`), so for any real + screenshot the blob path is the **normal** one, not an edge case. + Two consequences the plan could not have anticipated: the MIME must + come from the event block (the hub stores the leaf as + `application/octet-stream`), and the fetched body is base64 **text**, + so it needs one decode — `getBlobDataUrl` would double-encode it. + Neither client had ever resolved an externalized payload leaf; + mobile's blob code is all artifact viewers, a different case. - **R5 — subagent panel.** The dock's flat name-match rows (`stateDock.ts`) gain a detail panel: click a subagent chip → side panel with that subagent's filtered event stream (events already