Просмотр исходного кода

feat(web): render web_search/web_fetch output as a web card

Consume the card:'web' result view (structured sources + answer for search, the
URL and HTTP status for fetch) the web backend PR added. WebBlock
(ui-primitives) draws both kinds via the kind discriminant: a citation list of
safe external links (the MarkdownText protocol allowlist, title-or-hostname
label), a truncation indicator, a height cap matching TerminalBlock; a fetch
summary for the other kind. web-card-model is the single resultView derivation;
a keyed WebRow registers under web_search and web_fetch with the card resident
under its summary. The generic fallback and the details panel are web-aware.
Fixture gains web_search and web_fetch turns for the built-boot snapshot.
Chinesezjc 2 месяцев назад
Родитель
Сommit
f6802ee019

+ 6 - 0
.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
+2026-07-30-web-result-card-frontend.md: cf2dd26a8f6eebe6d8d5d275a462151b7c3274a2
+2026-07-30-web-result-card-frontend.zh.md: 505243f7a31df1a5be0381a9399c5283272d1fe8

+ 49 - 0
.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md

@@ -0,0 +1,49 @@
+# Agent Note: Web result card frontend — rendering the web render intent in the browser
+
+Status: implemented
+
+English | [中文](2026-07-30-web-result-card-frontend.zh.md)
+
+## Problem
+
+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 `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), and for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves).
+
+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 same allowlist MarkdownText applies to untrusted assistant-authored links.** 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:` 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.
+
+## 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 matches MarkdownText's, so untrusted 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); 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 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](2026-07-30-web-result-card.md) — 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](2026-07-28-web-terminal-card.md) — 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](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `web` arm.

+ 49 - 0
.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md

@@ -0,0 +1,49 @@
+# Agent Note: Web result 卡片前端 —— 在浏览器渲染 web 渲染意图
+
+Status: implemented
+
+[English](2026-07-30-web-result-card-frontend.md) | 中文
+
+## Problem
+
+`web_search` 和 `web_fetch` 工具声明了 `card: 'web'` result view([web result card](2026-07-30-web-result-card.md)):一个 `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-conversation/src/client/contract/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,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。
+
+**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用的同一 allowlist。** 一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。
+
+**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。
+
+卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片。
+
+## Consequences
+
+`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到契约的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。
+
+一条独立的后续 PR 会统一整行折叠/展开交互,并把每张常驻卡片(terminal、diff、web)一次性翻成 expand-gated;本卡片遵循当前的常驻约定,而非抢先做那次改动。
+
+## Alternatives considered
+
+**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而契约已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。
+
+**重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。
+
+**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 与 MarkdownText 的一致,因此不受信任的链接无论在何处渲染都行为相同。
+
+## Testing
+
+`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。
+
+`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/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](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。
+- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。
+- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。

+ 78 - 1
packages/client/connection/src/client/fixture.ts

@@ -136,6 +136,54 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
   [TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
 }
 
+/**
+ * One inline-authored source for the `web_search` fixture. Structurally the
+ * contract's `WebSource`; authored locally because the fixture cannot import the
+ * web tool, and kept minimal so a missing optional field renders its fallback.
+ */
+interface WebSourceFixture {
+  url: string
+  title?: string
+  snippet?: string
+  publishedAt?: string
+}
+
+/**
+ * The structured `web_search` result view for fixture turn 66, authored inline
+ * because this client-side fixture cannot import the web tool that projects it.
+ * The sources exercise the citation list's features: a titled source with a
+ * snippet and a date, a source with no title (its hostname labels the link), and
+ * a source with a snippet but no date. `truncated` marks the capped indicator.
+ */
+const WEB_SEARCH_RESULT: { answer: string; sources: WebSourceFixture[]; truncated: boolean } = {
+  answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',
+  sources: [
+    {
+      url: 'https://github.com/deepseek-ai/deepseek-harness',
+      title: 'DeepSeek Harness — plugin-based agent harness',
+      snippet: 'Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.',
+      publishedAt: '2026-07-01',
+    },
+    {
+      url: 'https://www.deepseek.com/blog/harness-architecture',
+      snippet: 'The capability-seam pattern splits each capability into interface, implementation, and consumer packages.',
+    },
+    {
+      url: 'https://docs.deepseek.com/harness/plugins',
+      title: 'Writing a harness plugin',
+      publishedAt: '2026-06-15',
+    },
+  ],
+  truncated: true,
+}
+
+/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */
+const WEB_FETCH_RESULT: { url: string; statusCode: number; truncated: boolean } = {
+  url: 'https://www.deepseek.com/blog/harness-architecture',
+  statusCode: 200,
+  truncated: false,
+}
+
 const DEEPSEEK_REASONING = {
   efforts: [
     { id: 'off', name: 'Off' },
@@ -296,8 +344,20 @@ function buildAlphaLog(): SessionEvent[] {
   // strip empty and take the todo surfaces' own coverage with it.
   toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
 
+  // Turns 66-67: the web render intent — a web_search whose result view carries
+  // structured sources plus an answer (the citation list, one source lacking a
+  // title so its hostname labels the link, the capped indicator on), and a
+  // web_fetch whose result view carries the fetched URL and its HTTP status.
+  // Both keep a generic pending call view and add the `web` card only at
+  // result time, which is the contract's result-only web shape. Named after
+  // the real tools so they hit the keyed WebRow registration. Ordered BEFORE
+  // the todo turn for the same reason turn 65 is: the standing plan retires at
+  // the next turn/start, so a turn after it would empty the dock's plan strip.
+  toolTurn(66, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
+  toolTurn(67, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
+
   const todoArgs = JSON.stringify({ todos: fixtureTodos })
-  toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
+  toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
   // The real tool appends the snapshot mid-execution — between tool/call and
   // tool/result — so the fixture reproduces that exact ordering (the last
   // toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -336,6 +396,13 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
       return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
     case 'write':
       return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
+    // The web tools keep a GENERIC pending card and add the `web` result card
+    // only at result time (the contract's result-only web shape); their pending
+    // kind matches the result kind so a call and its result read as one category.
+    case 'web_search':
+      return { card: 'generic', title: `Search ${str(args.query)}`, kind: 'search', rawInput: args }
+    case 'web_fetch':
+      return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
     default:
       return undefined // echo et al: the documented no-view fallback path
   }
@@ -344,6 +411,16 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
 function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
   const call = presentCall(name, argsRaw)
   if (call === undefined) return undefined
+  // The web tools keep a generic pending card, so their result card is chosen
+  // by tool name rather than by the pending card tag: the structured `web`
+  // card the frontend consumes, with the model-facing text kept as the
+  // capability-less fallback content.
+  if (name === 'web_search') {
+    return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT, content: text(resultText) }
+  }
+  if (name === 'web_fetch') {
+    return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT, content: text(resultText) }
+  }
   switch (call.card) {
     case 'terminal':
       // The sample's own exit status, authored beside it: re-parsing the

+ 6 - 0
packages/client/ui-conversation/src/client/apply.ts

@@ -19,6 +19,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
 import { ChatView } from './chat/ChatView.tsx'
 import { StatsLine } from './chat/StatsLine.tsx'
 import { bashToolviewSample } from './toolviews/bash-sample.tsx'
+import { webToolview } from './toolviews/web-row.tsx'
 import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
 import { todoToolview } from './toolviews/todo-row.tsx'
 import { todoDockEntry } from './skeleton/TodoPanel.tsx'
@@ -254,6 +255,11 @@ export function apply(ctx: Context): void {
   // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
   ctx.plugin(bashToolviewSample)
 
+  // The web rows ride the same seam: one WebRow registered under both
+  // web_search and web_fetch, rendering the completed retrieval's web card
+  // resident under the summary (a product registration, not a sample).
+  ctx.plugin(webToolview)
+
   // The todo_write row rides the same seam (a product registration, not a sample).
   ctx.plugin(todoToolview)
 

+ 15 - 0
packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css

@@ -0,0 +1,15 @@
+/* The generic card grows a resident web card under its summary row when the
+   tool declares the `web` render intent but has no keyed row of its own (the
+   web_search/web_fetch rows register their own WebRow). A column around the
+   ToolRow keeps the row's own 24px height. */
+
+.card {
+  display: flex;
+  flex-direction: column;
+}
+
+/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
+   and replaces the primitive's standalone vertical margin with the flow's. */
+.web {
+  margin: 4px 0 4px 22px;
+}

+ 17 - 2
packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx

@@ -7,12 +7,14 @@
 import type { ReactNode } from 'react'
 import {
   IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
-  IconThinkOutline14,
+  IconThinkOutline14, WebBlock,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ToolRowOwnerProps } from '../contract/slots.ts'
 import { terminalCardModel } from '../contract/terminal-card-model.ts'
+import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
 import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
 import { ToolRow } from './ToolRow.tsx'
+import css from './GenericToolCard.module.css'
 
 /** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
 const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
@@ -29,8 +31,9 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
 export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
   const model = toolRowModel(toolName, block, cwd)
   const terminal = terminalCardModel(block, cwd)
+  const web = webCardModel(block)
   const singleFile = model.filePath !== undefined
-  return (
+  const row = (
     <ToolRow
       variant={model.variant}
       toolName={toolName}
@@ -47,4 +50,16 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
       onOpenFile={singleFile ? openFile : undefined}
     />
   )
+  // A web-declaring tool without its own keyed row lands here; its card is
+  // resident under the summary, mirroring WebRow (and BashRow's terminal card).
+  if (web === null) return row
+  return (
+    <div className={css.card}>
+      {row}
+      <WebBlock
+        {...(web.kind === 'search' ? { ...web, maxSources: CHAT_WEB_MAX_SOURCES } : web)}
+        className={css.web}
+      />
+    </div>
+  )
 }

+ 70 - 0
packages/client/ui-conversation/src/client/contract/web-card-model.ts

@@ -0,0 +1,70 @@
+/**
+ * Pure derivation of the web-card props from a frozen call slice: the
+ * `card:'web'` render intent the `web_search`/`web_fetch` tools declare at
+ * result time arrives on the snapshot as `resultView`, and this is the one
+ * place that turns it into what {@link WebBlock} draws. Both conversation
+ * render sites (the chat tool row's resident/expanded body and the details
+ * panel's Output section) call this, so the sources and fetch summary they
+ * show are derived once.
+ *
+ * The web card is result-only by contract: those tools keep a generic pending
+ * call view, so there is nothing to derive while the call is still running and
+ * a running call always takes the generic path.
+ * @module
+ */
+import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ToolCallBlock } from './tool-call-model.ts'
+
+/**
+ * Sources the chat row's web body shows before collapsing the middle — half
+ * the primitive's own default, which the details panel keeps. A chat row is a
+ * summary surface inside the message flow: the flow must stay scannable across
+ * many calls, while the details panel is the single-call reading surface. A
+ * design constant of this UI's row geometry, not a deployment choice, so it is
+ * fixed here rather than a plugin Config field.
+ */
+export const CHAT_WEB_MAX_SOURCES = 8
+
+/**
+ * Derive the web-card props for a tool call, or null when this call is not a
+ * web card and belongs on the generic path.
+ *
+ * The result side supplies the whole card: the sources and answer for a
+ * `search`, the URL and status for a `fetch`. Cases producing null, all of
+ * them the documented generic-card default:
+ *
+ * - A running call (no `resultView` yet): the web tools keep a generic pending
+ *   card, so nothing web-shaped exists until the call settles.
+ * - A settled call whose result view is not a web card — including a `card`
+ *   value this UI version does not know, which arrives over the wire and so
+ *   cannot be trusted to be one of the compiled variants, and a generic result
+ *   view (a web tool's error path returns the generic card, whose text the
+ *   generic path preserves).
+ * @param block - RunningToolCall or ToolResultNode off the snapshot caches.
+ * @returns the web-card props, or null for the generic path.
+ */
+export function webCardModel(block: ToolCallBlock): WebBlockProps | null {
+  // Running calls have no result view; the web card is result-only.
+  if (!('kind' in block)) return null
+  const result = block.resultView
+  if (result?.card !== 'web') return null
+  if (result.kind === 'search') {
+    return {
+      kind: 'search',
+      answer: result.answer,
+      sources: result.sources.map(source => ({
+        url: source.url,
+        title: source.title,
+        snippet: source.snippet,
+        publishedAt: source.publishedAt,
+      })),
+      truncated: result.truncated,
+    }
+  }
+  return {
+    kind: 'fetch',
+    url: result.url,
+    statusCode: result.statusCode,
+    truncated: result.truncated,
+  }
+}

+ 6 - 0
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css

@@ -106,3 +106,9 @@
 .terminal {
   margin: 0;
 }
+
+/* Same rule for the web card: it sits under the section label, so the section
+   owns the spacing rather than the primitive's own vertical margin. */
+.web {
+  margin: 0;
+}

+ 10 - 3
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx

@@ -7,11 +7,12 @@
 // share the store seat exists for) and derives the call material from the
 // session snapshot — no data of its own.
 
-import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import { CodeBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import type { DetailsSlotProps } from '../contract/slots.ts'
 import { terminalCardModel } from '../contract/terminal-card-model.ts'
+import { webCardModel } from '../contract/web-card-model.ts'
 import type { ToolCallBlock } from '../contract/tool-call-model.ts'
 import css from './DetailsPanel.module.css'
 
@@ -127,8 +128,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
  * The Output section's body for the selected call. A terminal-card call — a
  * shell command's call/result views — renders through the shared TerminalBlock
  * at the primitive's own full height allowance, so column-aligned output keeps
- * its alignment and scrolls sideways instead of folding. Every other call, and
- * a running call with no terminal card yet, keeps the flattened text form.
+ * its alignment and scrolls sideways instead of folding. A web-card call — a
+ * `web_search`/`web_fetch` result — renders through WebBlock at its own full
+ * source-list allowance. Every other call, and a running call with no card
+ * yet, keeps the flattened text form.
  * @param props.material - the selected call's material from {@link materialFor}.
  * @param props.cwd - the session workspace root, resolving the terminal view's cwd.
  * @returns the Output section's body element.
@@ -147,6 +150,10 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
       </>
     )
   }
+  const web = webCardModel(material.block)
+  // Full source-list allowance here (the panel is the single-call reading
+  // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES.
+  if (web !== null) return <WebBlock {...web} className={css.web} />
   // A settled call always carries the result node the flattened form needs;
   // the running shape has no result to flatten.
   if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>

+ 95 - 0
packages/client/ui-conversation/src/client/toolviews/web-row.module.css

@@ -0,0 +1,95 @@
+/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus
+   the web card the row stacks under its summary line, mirroring the bash row's
+   resident terminal card. */
+
+/* Summary line over the web card; the summary row keeps its own 24px height,
+   so the card is a column around it rather than a change to it. */
+.card {
+  display: flex;
+  flex-direction: column;
+}
+
+/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
+   and replaces the primitive's standalone vertical margin with the flow's. */
+.web {
+  margin: 4px 0 4px 22px;
+}
+
+.root {
+  position: relative; /* sweep-glare overlay anchor */
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  height: 24px;
+  min-width: 0;
+}
+
+/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
+.root[data-state='running']::after {
+  content: '';
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  width: 300px;
+  background: linear-gradient(
+    90deg,
+    transparent 0%,
+    color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
+    transparent 100%
+  );
+  animation: dsh-web-row-sweep 2.6s ease-out infinite;
+  pointer-events: none;
+}
+
+@keyframes dsh-web-row-sweep {
+  0% { left: -300px; }
+  90%, 100% { left: 100%; }
+}
+
+.leading {
+  flex: none;
+  width: 16px;
+  height: 16px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 6px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.title {
+  flex: none;
+  font-size: 14px;
+  line-height: 24px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.sep {
+  flex: none;
+  width: 2px;
+  height: 2px;
+  border-radius: 1px;
+  margin: 0 8px;
+  background: var(--dsw-alias-label-caption);
+}
+
+.summary {
+  flex: 1 1 auto;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 14px;
+  line-height: 24px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.visuallyHidden {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip: rect(0 0 0 0);
+  white-space: nowrap;
+}

+ 95 - 0
packages/client/ui-conversation/src/client/toolviews/web-row.tsx

@@ -0,0 +1,95 @@
+// Web toolview registrant: third-party posture over the keyed toolview hole
+// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
+// Registered under BOTH web_search and web_fetch, since both declare the one
+// `web` render intent and render through the one WebBlock family; the row
+// discriminates on the toolName only to pick its icon and title.
+//
+// A web tool declares the `web` render intent at result time, so this row
+// renders the completed retrieval through WebBlock resident below its summary,
+// the same posture BashRow uses for the terminal card: no expand control on the
+// row itself, not a details-panel target, and the block's own expander keeps a
+// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is
+// passed as maxSources — the chat flow's tighter cap over the block's default
+// of 16). Until the call settles there is no web card (the tools keep a generic
+// pending view), so a running row is the summary line alone.
+
+import type { Context } from 'cordis'
+import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ToolRowProps } from '../contract/slots.ts'
+import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
+import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
+import css from './web-row.module.css'
+
+/** web_fetch reads one URL; web_search queries. Titles are figma literals. */
+const WEB_TITLES: Record<string, string> = {
+  web_search: 'Search',
+  web_fetch: 'Fetch',
+}
+
+/** Leading icon per tool, yielding to the state semantic while failed/stopped. */
+function leadingFor(toolName: string, state: ToolRowState) {
+  switch (state) {
+    case 'error': return <StateDot state="error" />
+    case 'stopped': return <StateDot state="warning" />
+    // Running keeps the icon — the row sweep carries the in-flight signal.
+    default: return toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
+  }
+}
+
+/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
+function stateStatus(state: ToolRowState): string | null {
+  switch (state) {
+    case 'running': return '运行中'
+    case 'error': return '失败'
+    case 'stopped': return '已停止'
+    default: return null
+  }
+}
+
+/**
+ * Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with
+ * the completed retrieval's web card resident below it. The summary row is not
+ * a details-panel control (tool rows stopped being one), so the card's own
+ * links and expander are the row's only interactions.
+ */
+export function WebRow({ toolName, block }: ToolRowProps) {
+  const model = toolRowModel(toolName, block)
+  const web = webCardModel(block)
+  const status = stateStatus(model.state)
+  return (
+    <div className={css.card}>
+      <div className={css.root} data-variant="web" data-tool={toolName} data-state={model.state}>
+        <span className={css.leading}>{leadingFor(toolName, model.state)}</span>
+        {status !== null && <span className={css.visuallyHidden}>{status}</span>}
+        <span className={css.title}>{WEB_TITLES[toolName] ?? model.title}</span>
+        <span className={css.sep} aria-hidden />
+        <span className={css.summary}>{model.summary}</span>
+      </div>
+      {web !== null && (
+        <WebBlock
+          {...(web.kind === 'search' ? { ...web, maxSources: CHAT_WEB_MAX_SOURCES } : web)}
+          className={css.web}
+        />
+      )}
+    </div>
+  )
+}
+
+/**
+ * The web rows as a plain registrant plugin, riding the same load-order seam as
+ * the bash sample: `inject: ['conversation']` guarantees the chat entry (and
+ * with it the 'conversation.chat.toolview' declaration) is on the ledger. One
+ * WebRow component registers under both web tool names.
+ */
+export const webToolview = {
+  name: 'web-toolview',
+  inject: ['slots', 'conversation'],
+  /**
+   * Register the web row under both web tool names' keyed toolview holes.
+   * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
+   */
+  apply(ctx: Context): void {
+    ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow)
+    ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow)
+  },
+}

+ 5 - 4
packages/client/ui-conversation/tests/chat-apply.spec.tsx

@@ -80,12 +80,13 @@ describe('apply wiring', () => {
     await b.runtime.dispose()
   })
 
-  it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
+  it('mounts the bash sample, the web rows, and the todo row as keyed entries through the load-order seam', async () => {
     const b = await bench()
-    // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
-    // service being present implies the chat entry declared the hole first.
+    // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
+    // service being present implies the chat entry declared the hole first. The
+    // web rows register one component under both web tool names.
     const entries = b.slots.entries('conversation.chat.toolview')
-    expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
+    expect(entries.map(e => e.options.key)).toEqual(['bash', 'web_search', 'web_fetch', 'todo_write'])
     // Stats stick with the composer (not inside ChatView).
     expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
     await b.runtime.dispose()

+ 255 - 0
packages/client/ui-conversation/tests/web-card.spec.tsx

@@ -0,0 +1,255 @@
+// @vitest-environment jsdom
+// The web render intent on the web side: the pure webCardModel derivation over
+// resultView, and the conversation render sites that consume it — the keyed
+// WebRow (registered under both web_search and web_fetch), the GenericToolCard
+// render-site fallback, and the details panel's Output section. Mirrors
+// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat
+// row's resident card, the panel arm, and the keyed registration.
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type {
+  ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
+import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts'
+import { createChatStore } from '../src/client/stores.ts'
+import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
+import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
+import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx'
+
+afterEach(cleanup)
+
+const SID = 's1' as SessionId
+
+const SEARCH_ARGS = '{"query":"deepseek harness"}'
+const FETCH_ARGS = '{"url":"https://example.com/page"}'
+
+/** A web_search result view; overrides tune the sources / answer / truncation. */
+const resultSearch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'search' }>>): ToolResultView => ({
+  card: 'web', kind: 'search', truncated: false,
+  answer: 'A short answer.',
+  sources: [
+    { url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
+    { url: 'https://plain.example.org/b' },
+  ],
+  ...over,
+})
+
+/** A web_fetch result view. */
+const resultFetch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>>): ToolResultView => ({
+  card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over,
+})
+
+const runningSearch = (over?: Partial<RunningToolCall>): RunningToolCall => ({
+  callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
+  turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over,
+})
+
+const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
+  kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
+  call: { name: 'web_search', argsRaw: SEARCH_ARGS },
+  callTime: 1_000,
+  content: [{ type: 'text', text: 'search text' }], isError: false,
+  callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over,
+})
+
+const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
+  kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2',
+  call: { name: 'web_fetch', argsRaw: FETCH_ARGS },
+  callTime: 1_000,
+  content: [{ type: 'text', text: 'fetch body' }], isError: false,
+  callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over,
+})
+
+describe('webCardModel', () => {
+  it('derives a search card from the result view, projecting every source field', () => {
+    expect(webCardModel(settledSearch())).toEqual({
+      kind: 'search',
+      answer: 'A short answer.',
+      truncated: false,
+      sources: [
+        { url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
+        { url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined },
+      ],
+    })
+  })
+
+  it('carries the search truncation flag and an absent answer', () => {
+    const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } }))
+    expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] })
+  })
+
+  it('derives a fetch card from the result view', () => {
+    expect(webCardModel(settledFetch())).toEqual({
+      kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false,
+    })
+    expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) })))
+      .toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true })
+  })
+
+  it('returns null for a running call, since the web card is result-only', () => {
+    expect(webCardModel(runningSearch())).toBeNull()
+    // Even a running call that somehow carried a web call view stays generic:
+    // the derivation reads resultView only.
+    expect(webCardModel(runningSearch({ callView: null }))).toBeNull()
+  })
+
+  it('returns null for a settled call whose result view is not a web card', () => {
+    expect(webCardModel(settledSearch({ resultView: null }))).toBeNull()
+    expect(webCardModel(settledSearch({ resultView: { card: 'generic' } }))).toBeNull()
+    // A card tag this UI version does not know arrives over the wire; the
+    // documented generic-card default takes it, not a crash.
+    const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView
+    expect(webCardModel(settledSearch({ resultView: future }))).toBeNull()
+  })
+})
+
+describe('chat row web body', () => {
+  const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({
+    callId: block.callId, toolName, block, openFile: vi.fn(),
+  })
+  // WebRow reads only toolName/block off the full runtime share; the standard
+  // kit is unused, so the cast supplies the owner slice alone (as BashRow's
+  // tests do for the terminal card).
+  const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps =>
+    ownerProps(block, toolName) as unknown as ToolRowProps
+
+  it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => {
+    expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16)
+    const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
+    // The summary row plus the resident card, without any expand gesture on the row itself.
+    expect(view.getByText('Search')).toBeTruthy()
+    expect(view.getByText('Titled')).toBeTruthy()
+    expect(view.getByText('excerpt')).toBeTruthy()
+    // hostname fallback for the source with no title
+    expect(view.getByText('plain.example.org')).toBeTruthy()
+  })
+
+  it('the WebRow renders the fetch card resident, titled Fetch', () => {
+    const view = render(<WebRow {...rowProps(settledFetch(), 'web_fetch')} />)
+    expect(view.getByText('Fetch')).toBeTruthy()
+    // The url shows in the summary row and as the card's link; scope to the card.
+    const card = view.container.querySelector('[data-web="fetch"]')
+    expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
+    expect(view.getByText('HTTP 200')).toBeTruthy()
+  })
+
+  it('a running web call is the summary row alone (no card until it settles)', () => {
+    const view = render(<WebRow {...rowProps(runningSearch(), 'web_search')} />)
+    expect(view.getByText('Search')).toBeTruthy()
+    expect(view.queryByText('Titled')).toBeNull()
+    expect(view.container.querySelector('[data-web]')).toBeNull()
+  })
+
+  it('a failed web call keeps the summary row without the card', () => {
+    const view = render(<WebRow {...rowProps(settledSearch({
+      isError: true, resultView: { card: 'generic' },
+    }), 'web_search')} />)
+    expect(view.getByText('Search')).toBeTruthy()
+    expect(view.container.querySelector('[data-web]')).toBeNull()
+    // The row reflects the error state so the summary line still reads as failed.
+    expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
+  })
+
+  it('the GenericToolCard fallback also renders a resident web card for a web-declaring tool', () => {
+    // A web-declaring tool without its own keyed row lands on the fallback; its
+    // card is resident there too.
+    const view = render(<GenericToolCard {...ownerProps(settledSearch({
+      call: { name: 'fx-web', argsRaw: SEARCH_ARGS },
+    }), 'fx-web')} />)
+    expect(view.getByText('Titled')).toBeTruthy()
+    expect(view.container.querySelector('[data-web="search"]')).not.toBeNull()
+  })
+
+  it('the GenericToolCard fallback keeps the plain row for a non-web call', () => {
+    const view = render(<GenericToolCard {...ownerProps(settledSearch({
+      call: { name: 'echo', argsRaw: '{}' }, callView: null, resultView: null,
+    }), 'echo')} />)
+    expect(view.container.querySelector('[data-web]')).toBeNull()
+  })
+})
+
+describe('DetailsPanel web Output section', () => {
+  function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) {
+    localStorage.clear()
+    const chat = createChatStore().create()
+    if (selection !== null) chat.actions.select(selection)
+    const sessions = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
+    const workspaces = createSnapshotStore<WorkspaceListState>({
+      items: [], state: 'idle', phase: 'ready', error: null,
+      baselinesReady: true, recentWorkspaceId: undefined,
+    })
+    return render(
+      <DetailsPanel
+        sessionId={SID}
+        useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
+        useSessions={bindSnapshotSelector(sessions)}
+        useWorkspaces={bindSnapshotSelector(workspaces)}
+        useInput={(() => { throw new Error('unused') })}
+        inputActions={{ setDraft: () => {}, submit: () => {} }}
+        useProjection={(() => undefined)}
+        useStore={bindSnapshotSelector(chat)}
+        actions={chat.actions}
+        closeDetails={vi.fn()}
+      />,
+    )
+  }
+
+  function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
+    return {
+      sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
+      pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
+      openState: 'open', openError: null, hasMore: false, loadingOlder: false,
+      promptError: null, blank: false, lastAgentError: null, ...over,
+    }
+  }
+
+  it('renders the search card at full source allowance', () => {
+    const view = mount(snapshot({ nodes: [settledSearch()] }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
+    expect(view.getByText('Titled')).toBeTruthy()
+    expect(view.getByText('excerpt')).toBeTruthy()
+    // The Input JSON section survives beside it.
+    expect(view.getByText(/"query"/)).toBeTruthy()
+  })
+
+  it('renders the fetch card', () => {
+    const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' })
+    const card = view.container.querySelector('[data-web="fetch"]')
+    expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
+    expect(view.getByText('HTTP 200')).toBeTruthy()
+  })
+
+  it('a non-web result keeps the flattened pre form', () => {
+    const view = mount(snapshot({
+      nodes: [settledSearch({ callView: null, resultView: null })],
+    }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
+    expect(view.container.querySelector('[data-web]')).toBeNull()
+    const output = view.getByText('Output').closest('section')
+    expect(output?.querySelector('pre')?.textContent).toContain('search text')
+  })
+})
+
+describe('web toolview registration', () => {
+  it('registers one WebRow under both web_search and web_fetch', () => {
+    const registered: { key: string; component: unknown }[] = []
+    const ctx = {
+      slots: {
+        register: (options: { name: string; key: string }, component: unknown) => {
+          registered.push({ key: options.key, component })
+          return () => {}
+        },
+      },
+    } as unknown as import('cordis').Context
+    webToolview.apply(ctx)
+    expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch'])
+    // One component under both keys, not two thin rows.
+    expect(registered[0]?.component).toBe(WebRow)
+    expect(registered[1]?.component).toBe(WebRow)
+    // The load-order seam the render site depends on.
+    expect(webToolview.inject).toEqual(['slots', 'conversation'])
+  })
+})

+ 124 - 0
packages/client/ui-primitives/src/WebBlock.module.css

@@ -0,0 +1,124 @@
+/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface,
+   16px vertical margin) so a web card, a terminal card, and a fenced code block
+   read as one family. A source list is prose, not aligned output, so it wraps
+   normally rather than scrolling horizontally like a terminal card's output. */
+
+.block {
+  --dsl-web-radius: 12px;
+
+  margin: 16px 0;
+  padding: 12px 14px;
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-markdown-code-block);
+  border-radius: var(--dsl-web-radius);
+}
+
+/* The provider answer reads as body prose above the citation list; its own
+   MarkdownText margins are trimmed so the list sits tight under it. */
+.answer {
+  margin-bottom: 8px;
+}
+
+.answer > :global(div) > :first-child {
+  margin-top: 0;
+}
+
+.answer > :global(div) > :last-child {
+  margin-bottom: 0;
+}
+
+/* The citation list: ordered so each source reads as a numbered reference. */
+.sources {
+  margin: 0;
+  padding-left: 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.source {
+  min-width: 0;
+}
+
+.sourceLink {
+  color: var(--dsw-alias-state-business-primary);
+  font-size: 14px;
+  line-height: 20px;
+  word-break: break-word;
+}
+
+.sourceLink:hover {
+  text-decoration: underline;
+}
+
+.snippet {
+  margin-top: 2px;
+  color: var(--dsw-alias-label-secondary);
+  font-size: 13px;
+  line-height: 19px;
+  word-break: break-word;
+}
+
+.published {
+  margin-top: 2px;
+  color: var(--dsw-alias-label-tertiary);
+  font: var(--dsw-font-xs-13);
+}
+
+.expand {
+  display: block;
+  width: 100%;
+  padding: 0;
+  border: none;
+  background-color: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+  font: inherit;
+  text-align: left;
+}
+
+.expand:hover {
+  color: var(--dsw-alias-label-secondary);
+}
+
+.truncated {
+  margin-top: 8px;
+  color: var(--dsw-alias-label-tertiary);
+  font: var(--dsw-font-xs-13);
+}
+
+/* The fetch card is a compact summary: the URL over a status/truncation row. */
+.fetch {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.fetchUrl {
+  color: var(--dsw-alias-state-business-primary);
+  font-family: var(--ds-font-family-code);
+  font-size: 13px;
+  line-height: 19px;
+  word-break: break-all;
+}
+
+.fetchUrl:hover {
+  text-decoration: underline;
+}
+
+.fetchMeta {
+  display: flex;
+  align-items: baseline;
+  gap: 12px;
+}
+
+.status {
+  color: var(--dsw-alias-label-secondary);
+  font: var(--dsw-font-xs-13);
+}
+
+/* The fetch card's truncation note sits inline beside the status, so it drops
+   the search card's top margin. */
+.fetch .truncated {
+  margin-top: 0;
+}

+ 209 - 0
packages/client/ui-primitives/src/WebBlock.tsx

@@ -0,0 +1,209 @@
+// WebBlock: the surface for a completed web retrieval. One component draws both
+// kinds of the `web` render intent, discriminated by `kind`: a `search` shows an
+// optional provider answer above a citation list of sources (each 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 `fetch` shows a
+// compact retrieval summary (the linked final URL and its HTTP status). Both
+// mark a capped retrieval. Every link is a same-origin-safe external anchor:
+// only http(s) URLs become anchors (target/rel set), the same protocol allowlist
+// MarkdownText applies to untrusted assistant-authored links; an unparseable or
+// non-http URL renders as plain text. Geometry, radius, and fonts mirror
+// CodeBlock/TerminalBlock so a web card reads as one family with them; a long
+// source list caps at maxSources with a head/tail collapse using the same
+// arithmetic as TerminalBlock's output cap.
+
+import { useCallback, useState } from 'react'
+import clsx from 'clsx'
+import { MarkdownText } from './markdown/MarkdownText.tsx'
+import css from './WebBlock.module.css'
+
+/**
+ * Sources shown before the height cap collapses the middle of a citation list.
+ * Matches TerminalBlock's default output budget so both cards cut a long body
+ * at the same place; the chat row narrows it through the maxSources prop.
+ */
+export const DEFAULT_WEB_MAX_SOURCES = 16
+
+/**
+ * One citeable source drawn in a search card: the projection of the contract's
+ * `WebSource`, with the optional fields kept optional so a provider that
+ * returned only a URL still renders (its hostname becomes the label).
+ */
+export interface WebSourceView {
+  /** The source URL; becomes a safe external link when it is http(s). */
+  url: string
+  /** The source title; when absent the URL's hostname labels the link. */
+  title?: string | undefined
+  /** A short excerpt or summary shown under the link. */
+  snippet?: string | undefined
+  /** Publication/crawl timestamp, a provider-supplied string shown under the link. */
+  publishedAt?: string | undefined
+}
+
+/** A `web_search` card: an optional answer over a capped citation list. */
+export interface WebSearchBlockProps {
+  kind: 'search'
+  /** The provider-generated answer, rendered as markdown above the sources. */
+  answer?: string | undefined
+  /** The cited sources, in provider order. */
+  sources: WebSourceView[]
+  /** True when the tool cut the source list to its result cap. */
+  truncated: boolean
+  /** Sources shown before the middle collapses (default {@link DEFAULT_WEB_MAX_SOURCES}). */
+  maxSources?: number | undefined
+  /** Extra class merged onto the wrapper (callers position; this component draws). */
+  className?: string | undefined
+}
+
+/** A `web_fetch` card: the retrieval summary for one fetched URL. */
+export interface WebFetchBlockProps {
+  kind: 'fetch'
+  /** The final URL after allowed redirects; becomes a safe external link when http(s). */
+  url: string
+  /** HTTP status code of the fetched response. */
+  statusCode: number
+  /** True when the provider or the output cap cut the fetched content. */
+  truncated: boolean
+  /** Extra class merged onto the wrapper (callers position; this component draws). */
+  className?: string | undefined
+}
+
+/** A completed web retrieval card, discriminated by `kind`. */
+export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps
+
+/**
+ * The URL to link to, or undefined when the URL must render as plain text. The
+ * allowlist is MarkdownText's own for untrusted links: only http(s) becomes a
+ * navigable external anchor, so a `javascript:`/`data:`/`file:` URL or an
+ * unparseable string never reaches the DOM as an href.
+ * @param url - the source or fetch URL, from tool result content.
+ * @returns the href to use, or undefined for plain text.
+ */
+function safeHref(url: string): string | undefined {
+  try {
+    const { protocol } = new URL(url)
+    return protocol === 'http:' || protocol === 'https:' ? url : undefined
+  } catch {
+    return undefined
+  }
+}
+
+/**
+ * The link's visible label: the title when the provider gave one, otherwise the
+ * URL's hostname, falling back to the raw URL when it does not parse.
+ * @param url - the source URL.
+ * @param title - the provider title, if any.
+ * @returns the label text.
+ */
+function linkLabel(url: string, title: string | undefined): string {
+  if (title !== undefined && title !== '') return title
+  try {
+    return new URL(url).hostname
+  } catch {
+    return url
+  }
+}
+
+/**
+ * A single URL rendered as a safe external anchor, or as plain text when the
+ * URL is not an http(s) link.
+ * @param props.url - the URL to render.
+ * @param props.label - the visible label.
+ * @param props.className - class for the anchor or the plain span.
+ * @returns the anchor or span element.
+ */
+function SafeLink({ url, label, className }: { url: string; label: string; className?: string | undefined }) {
+  const href = safeHref(url)
+  if (href === undefined) return <span className={className}>{label}</span>
+  return (
+    <a className={className} href={href} target="_blank" rel="noopener noreferrer">
+      {label}
+    </a>
+  )
+}
+
+/**
+ * One source row in a search card: the safe link plus its snippet and date.
+ * @param props.source - the source to render.
+ * @returns the source list item.
+ */
+function SourceItem({ source }: { source: WebSourceView }) {
+  return (
+    <li className={css.source}>
+      <SafeLink url={source.url} label={linkLabel(source.url, source.title)} className={css.sourceLink} />
+      {source.snippet !== undefined && source.snippet !== '' && (
+        <div className={css.snippet}>{source.snippet}</div>
+      )}
+      {source.publishedAt !== undefined && source.publishedAt !== '' && (
+        <div className={css.published}>{source.publishedAt}</div>
+      )}
+    </li>
+  )
+}
+
+/**
+ * The search card body: the answer over the capped source list.
+ * @param props - see {@link WebSearchBlockProps}.
+ * @returns the search card element.
+ */
+function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_MAX_SOURCES, className }: WebSearchBlockProps) {
+  const [expanded, setExpanded] = useState(false)
+  const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
+  const hidden = sources.length - maxSources
+  const capped = hidden > 0 && !expanded
+  // Same split arithmetic as TerminalBlock's output cap, so a long body's head
+  // and tail slices agree between the two cards.
+  const headCount = Math.ceil(maxSources / 2)
+  const tailCount = maxSources - headCount
+  const head = capped ? sources.slice(0, headCount) : sources
+  const tail = capped ? sources.slice(sources.length - tailCount) : []
+  return (
+    <div className={clsx(css.block, className)} data-web="search">
+      {answer !== undefined && answer !== '' && (
+        <div className={css.answer}><MarkdownText text={answer} /></div>
+      )}
+      <ol className={css.sources}>
+        {head.map((source, index) => <SourceItem key={index} source={source} />)}
+        {hidden > 0 && (
+          <button
+            type="button"
+            className={css.expand}
+            aria-expanded={expanded}
+            aria-label={expanded ? '收起来源' : `展开其余 ${hidden} 条来源`}
+            onClick={onToggle}
+          >
+            {expanded ? '收起' : `… 其余 ${hidden} 条来源`}
+          </button>
+        )}
+        {tail.map((source, index) => <SourceItem key={sources.length - tailCount + index} source={source} />)}
+      </ol>
+      {truncated && <div className={css.truncated}>来源列表已截断</div>}
+    </div>
+  )
+}
+
+/**
+ * The fetch card body: the linked URL and its HTTP status.
+ * @param props - see {@link WebFetchBlockProps}.
+ * @returns the fetch card element.
+ */
+function WebFetchBlock({ url, statusCode, truncated, className }: WebFetchBlockProps) {
+  return (
+    <div className={clsx(css.block, css.fetch, className)} data-web="fetch">
+      <SafeLink url={url} label={url} className={css.fetchUrl} />
+      <div className={css.fetchMeta}>
+        <span className={css.status}>HTTP {statusCode}</span>
+        {truncated && <span className={css.truncated}>内容已截断</span>}
+      </div>
+    </div>
+  )
+}
+
+/**
+ * Render a completed web retrieval as a structured card.
+ * @param props - see {@link WebBlockProps}; `kind` selects the search or fetch body.
+ * @returns the web card element.
+ */
+export function WebBlock(props: WebBlockProps) {
+  return props.kind === 'search' ? <WebSearchBlock {...props} /> : <WebFetchBlock {...props} />
+}

+ 2 - 0
packages/client/ui-primitives/src/index.ts

@@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx'
 export type { JsonTreeProps } from './JsonTree.tsx'
 export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
 export type { TerminalBlockProps } from './TerminalBlock.tsx'
+export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
+export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
 export { CodeBlock } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'

+ 165 - 0
packages/client/ui-primitives/tests/web-block.spec.tsx

@@ -0,0 +1,165 @@
+// @vitest-environment jsdom
+// WebBlock: both kinds of the web card. The search card's answer, its citation
+// list with the title-or-hostname label fallback and optional snippet/date, the
+// source-list height cap and its expand control, and the truncated indicator;
+// the fetch card's linked URL, status, and truncation. Safe-link attributes on
+// both kinds: an http(s) URL becomes an external anchor (target/rel), any other
+// URL renders as plain text with no href.
+
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts'
+import type { WebSourceView } from '../src/index.ts'
+
+afterEach(cleanup)
+
+/** `count` sources with sequential hostnames, so the cap slices read distinctly. */
+function sources(count: number): WebSourceView[] {
+  return Array.from({ length: count }, (_value, index) => ({
+    url: `https://site-${index}.example.com/page`,
+    title: `Source ${index}`,
+  }))
+}
+
+describe('WebBlock search card', () => {
+  it('renders the answer above the citation list', () => {
+    const view = render(<WebBlock kind="search" answer="**Answer** text" sources={sources(2)} truncated={false} />)
+    expect(view.getByText('Answer')).toBeTruthy()
+    expect(view.getByText('Source 0')).toBeTruthy()
+    expect(view.getByText('Source 1')).toBeTruthy()
+  })
+
+  it('omits the answer block when there is no answer', () => {
+    const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
+    expect(view.container.querySelector('[class^="_answer_"]')).toBeNull()
+    const empty = render(<WebBlock kind="search" answer="" sources={sources(1)} truncated={false} />)
+    expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull()
+  })
+
+  it('labels a source by its title, and by hostname when the title is absent', () => {
+    const view = render(<WebBlock kind="search" truncated={false} sources={[
+      { url: 'https://example.com/a', title: 'Titled' },
+      { url: 'https://plain.example.org/b' },
+      { url: 'https://empty.example.net/c', title: '' },
+    ]} />)
+    expect(view.getByText('Titled')).toBeTruthy()
+    // No title / empty title: the hostname labels the link.
+    expect(view.getByText('plain.example.org')).toBeTruthy()
+    expect(view.getByText('empty.example.net')).toBeTruthy()
+  })
+
+  it('renders a source as a safe external anchor for an http(s) url', () => {
+    const view = render(<WebBlock kind="search" truncated={false} sources={[
+      { url: 'https://example.com/a', title: 'Titled' },
+    ]} />)
+    const anchor = view.getByText('Titled') as HTMLAnchorElement
+    expect(anchor.tagName).toBe('A')
+    expect(anchor.getAttribute('href')).toBe('https://example.com/a')
+    expect(anchor.getAttribute('target')).toBe('_blank')
+    expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
+  })
+
+  it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => {
+    const view = render(<WebBlock kind="search" truncated={false} sources={[
+      { url: 'javascript:alert(1)', title: 'Dangerous' },
+      { url: 'not a url' },
+    ]} />)
+    const unsafe = view.getByText('Dangerous')
+    expect(unsafe.tagName).toBe('SPAN')
+    expect(unsafe.getAttribute('href')).toBeNull()
+    // An unparseable url is not a link and cannot yield a hostname, so its raw
+    // text is the label.
+    const raw = view.getByText('not a url')
+    expect(raw.tagName).toBe('SPAN')
+  })
+
+  it('shows a source snippet and publication date when present, and omits them when absent or empty', () => {
+    const view = render(<WebBlock kind="search" truncated={false} sources={[
+      { url: 'https://a.example.com', title: 'A', snippet: 'excerpt', publishedAt: '2026-07-01' },
+      { url: 'https://b.example.com', title: 'B', snippet: '', publishedAt: '' },
+      { url: 'https://c.example.com', title: 'C' },
+    ]} />)
+    expect(view.getByText('excerpt')).toBeTruthy()
+    expect(view.getByText('2026-07-01')).toBeTruthy()
+    // The empty-string and absent arms both draw nothing beyond the link.
+    expect(view.container.querySelectorAll('[class^="_snippet_"]')).toHaveLength(1)
+    expect(view.container.querySelectorAll('[class^="_published_"]')).toHaveLength(1)
+  })
+
+  it('shows the truncated indicator only when the list was capped by the tool', () => {
+    const on = render(<WebBlock kind="search" sources={sources(1)} truncated />)
+    expect(on.getByText('来源列表已截断')).toBeTruthy()
+    cleanup()
+    const off = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
+    expect(off.queryByText('来源列表已截断')).toBeNull()
+  })
+
+  it('renders every source and no expand control under the cap', () => {
+    const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} maxSources={4} />)
+    expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
+    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
+  })
+
+  it('slices head and tail over the cap and expands on click', () => {
+    const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
+    // maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
+    expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent))
+      .toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9'])
+    const toggle = view.getByRole('button', { name: '展开其余 6 条来源' })
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(toggle.textContent).toBe('… 其余 6 条来源')
+
+    fireEvent.click(toggle)
+    expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10)
+    const collapse = view.getByRole('button', { name: '收起来源' })
+    expect(collapse.getAttribute('aria-expanded')).toBe('true')
+    expect(collapse.textContent).toBe('收起')
+
+    fireEvent.click(collapse)
+    expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
+  })
+
+  it('renders the head slice alone when the cap leaves no tail', () => {
+    const view = render(<WebBlock kind="search" sources={sources(5)} truncated={false} maxSources={1} />)
+    expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0'])
+    expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy()
+  })
+
+  it('caps at the documented default when maxSources is absent', () => {
+    const view = render(<WebBlock kind="search" sources={sources(DEFAULT_WEB_MAX_SOURCES + 1)} truncated={false} />)
+    expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES)
+    expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy()
+  })
+})
+
+describe('WebBlock fetch card', () => {
+  it('renders the fetched url as a safe external anchor and its HTTP status', () => {
+    const view = render(<WebBlock kind="fetch" url="https://example.com/page" statusCode={200} truncated={false} />)
+    const anchor = view.getByText('https://example.com/page') as HTMLAnchorElement
+    expect(anchor.tagName).toBe('A')
+    expect(anchor.getAttribute('href')).toBe('https://example.com/page')
+    expect(anchor.getAttribute('target')).toBe('_blank')
+    expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
+    expect(view.getByText('HTTP 200')).toBeTruthy()
+  })
+
+  it('renders a non-http fetch url as plain text with no href', () => {
+    const view = render(<WebBlock kind="fetch" url="file:///etc/passwd" statusCode={200} truncated={false} />)
+    const label = view.getByText('file:///etc/passwd')
+    expect(label.tagName).toBe('SPAN')
+    expect(label.getAttribute('href')).toBeNull()
+  })
+
+  it('shows the truncated indicator only when the content was cut', () => {
+    const on = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated />)
+    expect(on.getByText('内容已截断')).toBeTruthy()
+    cleanup()
+    const off = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated={false} />)
+    expect(off.queryByText('内容已截断')).toBeNull()
+  })
+
+  it('carries a non-200 status verbatim', () => {
+    const view = render(<WebBlock kind="fetch" url="https://example.com/missing" statusCode={404} truncated={false} />)
+    expect(view.getByText('HTTP 404')).toBeTruthy()
+  })
+})