Skip to content

Latest commit

 

History

History
50 lines (27 loc) · 10.2 KB

File metadata and controls

50 lines (27 loc) · 10.2 KB

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-tool/src/client/tool/models/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. The whole source list renders in one <ol> bounded by max-height: 320px and overflow-y: auto, so a list taller than that scrolls vertically in place rather than growing the card (source scroll). 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, the same resident posture BashRow uses. Both render sites show the same complete source list, bounded only by the card's own scroll height rather than by a row-versus-panel source cap. 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 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.

The whole-row collapse/expand interaction shared by every resident card (terminal, diff, web) is owned by the unified expand-and-inspect note; this card follows the resident convention rather than pre-empting that interaction.

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 contract adds it.

Render plain anchors without the protocol allowlist. Rejected: the URL is model-authored and unverified at this presentation boundary, 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.client.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 full source list rendering inside one scroll container with no expand control and <li value> numbering every source contiguously from 1.

packages/client/ui-tool/tests/web-card.client.spec.tsx mirrors terminal-card.spec.tsx at every wiring boundary: 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, 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-tool/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 contract that added the card: 'web' result arm and made the two tools emit it; this note owns its frontend consumer.
  • Web search source card scrolls instead of collapsing — replaces this note's source-list head/tail collapse with a fixed-height scroll container and removes CHAT_WEB_MAX_SOURCES and the primitive's own source cap; every other decision here still holds.
  • Web terminal card — the precedent this mirrors: a ui-primitives block, a single card-model derivation, keyed and fallback chat rows, and a details-panel arm, for the terminal render intent.
  • Tagged render-intent union for tool-call presentation — the card-tagged vocabulary; the Web client is now a full consumer of the web arm.