DSH / Atlas
2026-07-30implementedfeature

Web result card frontend — rendering the web render intent in the browser

Web result 卡片前端 —— 在浏览器渲染 web 渲染意图

The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web result card](2026-07-30-web-result-card.md)): 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 `res

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.

中文

Problem

web_searchweb_fetch 工具声明了 card: 'web' result view(web result card):一个 kind 标签联合,携带结构化的被引用 sources 加可选的 provider answer(kind: 'search'),或抓取的 URL 及其 HTTP 状态(kind: 'fetch')。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 resultView 投递到 ConversationSnapshot —— 但 Web 客户端忽略了它:一次已完成的 web 调用只渲染为摊平的模型可见文本,正是约定笔记所解释的、结构化视图要替代的那种有损渲染。web_search 到达读者时是每个 source 一行自由文本 markdown,而非可点击 source 的引用列表;web_fetch 是它的 markdown 正文,没有检索摘要。

Decision

WebBlock 是一个 ui-primitives 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 web 渲染意图:键控的 chat 工具行(web_search/web_fetch)、GenericToolCard 渲染点兜底,以及详情面板的 Output 区。ui-tool/src/client/tool/models/web-card-model.ts 是唯一把快照的 resultView 转成组件 props 的地方,镜像 terminal-card-model.ts,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 card 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 kind 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 HTTP undefined)。

一个组件绘制两种 kind,由 kind 判别。search 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 来源列表已截断 提示。fetch 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 内容已截断 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是约定把它们放在一个 card 标签下、用 kind 判别的原因。

链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集。 MarkdownText 还允许 mailto:,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 http:https: 时才成为可导航锚点,带 target="_blank"rel="noopener noreferrer"javascript:/data:/file:/mailto: URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。

几何镜像 CodeBlock/TerminalBlock(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。整份 source 列表渲染在单个 <ol> 里,由 max-height: 320pxoverflow-y: auto 约束,因此高于该值的列表在原地纵向滚动,而不是把卡片撑高(来源滚动)。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。

卡片在 chat 行中常驻于摘要行之下,与 BashRow 所用的同一常驻姿态。两个渲染点展示同一份完整的 source 列表,仅由卡片自身的滚动高度约束,而没有行与面板两级的 source 上限。键控行把一个 WebRow 组件注册在 web_searchweb_fetch 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(Search/Fetch)。没有自己键控行的 web 声明工具落到 GenericToolCard,它长出同一张常驻卡片。详情面板渲染该卡片,并在其下方渲染摊平的模型可见结果内容:web_fetch 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。

Consequences

WebBlock 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 web 能力的 UI(TUI)仍得到约定的回退 content;工具的 result 形状没有任何改变。answer 复用 MarkdownText,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。

每张常驻卡片(terminal、diff、web)共用的整行折叠/展开交互归统一展开与检视 note所有;本卡片遵循常驻约定,而非抢先做那套交互。

Alternatives considered

两个组件,每种 kind 一个。 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而约定已经把它们的差异表达为一个 card 标签下的 kind 判别;两个组件会重复共享表面并拆分安全链接逻辑。

重解析模型可见的渲染文本,而非消费结构化视图。 因约定笔记给出的同一理由拒绝:web_search 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 {url, title?, snippet?, publishedAt?}。结构化的 resultView 是唯一忠实来源,这正是后端约定添加它的原因。

不加协议 allowlist 直接渲染裸锚点。 拒绝:URL 在此展示边界处是模型创作、未经验证的,所以未过滤的 href 会让 javascript: URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还允许 mailto:)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。

Testing

packages/client/ui-primitives/tests/web-block.client.spec.tsx 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 target/rel 的外链,javascript:/file:/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及完整 source 列表渲染在单个滚动容器内、无展开控件、<li value> 从 1 起为每条 source 连续编号。

packages/client/ui-tool/tests/web-card.client.spec.tsx 在每个接线边界镜像 terminal-card.spec.tsx:webCardModel 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web kind);键控 WebRow 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;GenericToolCard 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 web_fetch 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 web_searchweb_fetch 两键下用一个组件的键控注册。该文件位于覆盖率 exclude 列表(ui-tool/src/*),因此覆盖率运行不度量它。

fixture(packages/client/connection/src/client/fixture.ts)添加 turn 66(web_search)与 67(web_fetch),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 web 卡片,匹配约定的 result-only web 形状,且以真实工具命名,使其命中键控 WebRow。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 turn/start 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 ?fixture 服务。

Related

  • Web result card —— 添加 card: 'web' result 支路并让两个工具发出它的后端契约;其前端消费方归本 note 所有。
  • Web search 来源卡片改为滚动而非折叠 —— 用定高滚动容器替换本笔记的 source 列表头/尾折叠,并移除 CHAT_WEB_MAX_SOURCES 与原语自身的 source 上限;本笔记的其余决策依然成立。
  • Web terminal card —— 本条所镜像的先例:一个 ui-primitives block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 terminal 渲染意图。
  • 工具调用呈现的标签化 render-intent union —— card 标签词汇;Web 客户端现在是 web 支路的完整消费者。