);
@@ -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 (
);
}
-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_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 (
);
}
@@ -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

;
+ }
+ // 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

;
+}
+
+/// 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 (
+
+
+
+ {t('tx.liveOutput')}
+ {clipped && {t('tx.outputClipped')}}
+
+
+
+ );
+}
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