- built-boot smoke asserts [data-variant=web][data-tool=web_search/fetch] (the keyed WebRow) instead of [data-web] (which WebBlock draws even on the GenericToolCard fallback, so a silent keyed-registration failure passed). - WebBlock renders an explicit empty-state note when a search returns no answer and no sources, instead of a blank <ol>; the chat row does not surface the raw result content, so the backend's 'No results found.' was otherwise invisible. - README (ui-conversation, ui-primitives) and the frontend Agent Note record the unknown-web-kind null arm, the details-panel flattened body, and the empty-search copy; pairings re-recorded.
9.9 KiB
Agent Note: Web result card frontend — rendering the web render intent in the browser
Status: implemented
English | 中文
Problem
The web_search and web_fetch tools declare a card: 'web' result view (web result card): a kind-tagged union carrying either the structured cited sources plus an optional provider answer (kind: 'search') or the fetched URL and its HTTP status (kind: 'fetch'). That view already reaches the browser — host, connection, and runtime deliver it onto ConversationSnapshot as resultView — but the Web client ignored it: a completed web call rendered only as its flattened model-facing text, the same lossy render the contract note explains the structured view exists to replace. A web_search reached the reader as one free-text markdown line per source rather than a citation list of clickable sources, and a web_fetch as its markdown body with no retrieval summary.
Decision
WebBlock is a ui-primitives component that renders a completed web retrieval, and every Web render site for a web call consumes the web render intent through it: the keyed chat tool rows (web_search/web_fetch), the GenericToolCard render-site fallback, and the details panel's Output section. ui-conversation/src/client/contract/web-card-model.ts is the single place that turns the snapshot's resultView into the component's props, mirroring terminal-card-model.ts, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a card value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose kind this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and HTTP undefined).
One component draws both kinds, discriminated by kind. A search shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a 来源列表已截断 indicator when the tool capped the list. A fetch shows a compact summary: the linked final URL, its HTTP status, and a 内容已截断 indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one card tag with a kind discriminant.
Links are safe by the http(s) subset of the allowlist MarkdownText applies to untrusted assistant-authored links — MarkdownText also permits mailto:, deliberately excluded here since a retrieval URL is never a mail address. A source or fetch URL becomes a navigable anchor only when its protocol is http: or https:, with target="_blank" and rel="noopener noreferrer"; a javascript:/data:/file:/mailto: URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse.
Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at maxSources with a head/tail collapse using TerminalBlock's exact split arithmetic (ceil(max/2) head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock.
The card is resident under the summary row in the chat rows, capped at CHAT_WEB_MAX_SOURCES (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split CHAT_TERMINAL_MAX_LINES draws for the terminal card, and the same resident posture BashRow uses. The keyed rows register one WebRow component under both web_search and web_fetch; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (Search/Fetch). A web-declaring tool without its own keyed row lands on GenericToolCard, which grows the same resident card. The details panel renders the card at the primitive's full source allowance and, below it, the flattened model-visible result content: a web_fetch card carries only the URL and status, so its fetched body is readable only here.
Consequences
WebBlock reads only the web view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view, and unlike the terminal card it needs no cwd resolution because a web view carries no path. A UI without the web capability (the TUI) still gets the contract's fallback content; nothing about the tools' result shape changed. MarkdownText is reused for the answer, so the answer's own untrusted-link handling and GFM rendering come for free.
A separate later PR unifies the whole-row collapse/expand interaction and will flip every resident card (terminal, diff, web) to expand-gated at once; this card follows the current resident convention rather than pre-empting that change.
Alternatives considered
Two components, one per kind. Rejected: the two shapes share their card chrome, their safe-link handling, and their truncation indicator, and the contract already expresses their difference as a kind discriminant under one card tag; two components would duplicate the shared surface and split the safe-link logic.
Reparse the model-facing render text instead of consuming the structured view. Rejected for the same reason the contract note gives: web_search's render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover {url, title?, snippet?, publishedAt?}. The structured resultView is the only faithful source, which is why the backend PR added it.
Render plain anchors without the protocol allowlist. Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a javascript: URL execute on click. The allowlist is the http(s) subset of MarkdownText's (which also permits mailto:), so untrusted retrieval links behave identically wherever they render.
Testing
packages/client/ui-primitives/tests/web-block.spec.tsx pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with target/rel, a javascript:/file:/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap.
packages/client/ui-conversation/tests/web-card.spec.tsx mirrors terminal-card.spec.tsx at every wiring seam: webCardModel's derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web kind); the keyed WebRow's resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the GenericToolCard fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a web_fetch's body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both web_search and web_fetch with one component. That file sits on the coverage exclude list (ui-conversation/src/*), so a coverage run measures none of it.
The fixture (packages/client/connection/src/client/fixture.ts) adds turns 66 (web_search) and 67 (web_fetch), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the web card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed WebRow. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next turn/start, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live ?fixture server.
Related
- Web result card — the backend PR that added the
card: 'web'result arm and made the two tools emit it; this is its deferred frontend consumer. - Web terminal card — the precedent this mirrors: a
ui-primitivesblock, a single card-model derivation, keyed and fallback chat rows, and a details-panel arm, for theterminalrender intent. - Tagged render-intent union for tool-call presentation — the
card-tagged vocabulary; the Web client is now a full consumer of thewebarm.