Bladeren bron

Merge branch 'master' into feat/gui-full-access-confirmation

Ziya 2 maanden geleden
bovenliggende
commit
ade8358e67
24 gewijzigde bestanden met toevoegingen van 1393 en 15 verwijderingen
  1. 6 0
      .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
  2. 49 0
      .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
  3. 49 0
      .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md
  4. 12 0
      apps/web/tests/built-boot.snapshot.ts
  5. 69 1
      packages/client/connection/src/client/fixture.ts
  6. 3 1
      packages/client/ui-conversation/README.md
  7. 3 1
      packages/client/ui-conversation/README.zh.md
  8. 6 0
      packages/client/ui-conversation/src/client/apply.ts
  9. 15 0
      packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css
  10. 14 2
      packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
  11. 84 0
      packages/client/ui-conversation/src/client/contract/web-card-model.ts
  12. 6 0
      packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css
  13. 24 3
      packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
  14. 95 0
      packages/client/ui-conversation/src/client/toolviews/web-row.module.css
  15. 92 0
      packages/client/ui-conversation/src/client/toolviews/web-row.tsx
  16. 4 3
      packages/client/ui-conversation/tests/chat-apply.spec.tsx
  17. 270 0
      packages/client/ui-conversation/tests/web-card.spec.tsx
  18. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  19. 1 1
      packages/client/ui-primitives/README.md
  20. 1 1
      packages/client/ui-primitives/README.zh.md
  21. 133 0
      packages/client/ui-primitives/src/WebBlock.module.css
  22. 242 0
      packages/client/ui-primitives/src/WebBlock.tsx
  23. 2 0
      packages/client/ui-primitives/src/index.ts
  24. 211 0
      packages/client/ui-primitives/tests/web-block.spec.tsx

+ 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: d6f4785e83335ca2dd5295516baf47c845ebf5bd
+2026-07-30-web-result-card-frontend.zh.md: ed95cbe39f4f0bf77ba5da64d664705a0841863f

+ 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), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`).
+
+One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant.
+
+**Links are safe by the http(s) subset of the allowlist MarkdownText applies to untrusted assistant-authored links** — MarkdownText also permits `mailto:`, deliberately excluded here since a retrieval URL is never a mail address. A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:`/`mailto:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse.
+
+**Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock.
+
+The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance and, below it, the flattened model-visible result content: a `web_fetch` card carries only the URL and status, so its fetched body is readable only here.
+
+## Consequences
+
+`WebBlock` reads only the web view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view, and unlike the terminal card it needs no cwd resolution because a web view carries no path. A UI without the `web` capability (the TUI) still gets the contract's fallback `content`; nothing about the tools' result shape changed. `MarkdownText` is reused for the answer, so the answer's own untrusted-link handling and GFM rendering come for free.
+
+A separate later PR unifies the whole-row collapse/expand interaction and will flip every resident card (terminal, diff, web) to expand-gated at once; this card follows the current resident convention rather than pre-empting that change.
+
+## Alternatives considered
+
+**Two components, one per kind.** Rejected: the two shapes share their card chrome, their safe-link handling, and their truncation indicator, and the contract already expresses their difference as a `kind` discriminant under one `card` tag; two components would duplicate the shared surface and split the safe-link logic.
+
+**Reparse the model-facing render text instead of consuming the structured view.** Rejected for the same reason the contract note gives: `web_search`'s render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover `{url, title?, snippet?, publishedAt?}`. The structured `resultView` is the only faithful source, which is why the backend PR added it.
+
+**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist is the http(s) subset of MarkdownText's (which also permits `mailto:`), so untrusted retrieval links behave identically wherever they render.
+
+## Testing
+
+`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap.
+
+`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it.
+
+The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server.
+
+## Related
+
+- [Web result card](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` 的 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 列表在 `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 额度渲染卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。
+
+## 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 allowlist(它还允许 `mailto:`)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。
+
+## 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 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 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` 支路的完整消费者。

+ 12 - 0
apps/web/tests/built-boot.snapshot.ts

@@ -111,6 +111,18 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
     expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
   }, { timeout: 10_000 })
 
+  // The web render intent reaches the assembled boot graph: the fixture's
+  // web_search / web_fetch turns render their keyed WebRow cards, proving the
+  // registration, wire projection, and card rendering survive the real bundle
+  // path (not just the per-package src benches). The selector pins the KEYED
+  // WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute
+  // WebBlock draws — the generic fallback renders the same WebBlock, so a silent
+  // keyed-registration failure would still satisfy a bare `[data-web]` check.
+  await waitFor(() => {
+    expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull()
+    expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull()
+  }, { timeout: 10_000 })
+
   // Every bundle injected its plugin-owned style tag (the loader's CSS path).
   const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
     .map(style => style.getAttribute('data-plugin'))

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

@@ -137,6 +137,44 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
   [TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
 }
 
+/**
+ * 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 snippet but no date, and a source with a title and a date but no snippet.
+ * `truncated` marks the capped indicator. The shape is the contract's own
+ * search view minus its wire discriminants.
+ */
+const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'search' }>, 'card' | 'kind'> = {
+  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: Omit<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind'> = {
+  url: 'https://www.deepseek.com/blog/harness-architecture',
+  statusCode: 200,
+  truncated: false,
+}
+
 const DEEPSEEK_REASONING = {
   efforts: [
     { id: 'off', name: 'Off' },
@@ -326,8 +364,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).
@@ -366,6 +416,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
   }
@@ -374,6 +431,17 @@ 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. The view carries no `content` copy (per the contract
+  // and the web-result-card note); a capability-less UI falls back to the raw
+  // `tool/result` content, which this fixture emits from `resultText`.
+  if (name === 'web_search') {
+    return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT }
+  }
+  if (name === 'web_fetch') {
+    return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT }
+  }
   switch (call.card) {
     case 'terminal':
       // The sample's own exit status, authored beside it: re-parsing the

+ 3 - 1
packages/client/ui-conversation/README.md

@@ -14,7 +14,9 @@ Logged non-user messages render as a default-collapsed `上下文注入` disclos
 
 Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
 
-A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
+A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
+
+A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
 
 Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
 

+ 3 - 1
packages/client/ui-conversation/README.zh.md

@@ -12,7 +12,9 @@
 
 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
 
-声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
+声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
+
+声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
 
 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
 

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

@@ -20,6 +20,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 { askQuestionToolview } from './toolviews/ask-question-row.tsx'
@@ -318,6 +319,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;
+}

+ 14 - 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 { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
 import { terminalCardModel, terminalFailed } 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> = {
@@ -34,13 +36,14 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
 export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
   const model = toolRowModel(toolName, block, cwd)
   const terminal = terminalCardModel(block, cwd)
+  const web = webCardModel(block)
   // A failing exit status is the terminal card's own error signal (the call
   // itself settles isError:false), surfaced as the row's red state dot.
   const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
     ? 'error'
     : model.state
   const singleFile = model.filePath !== undefined
-  return (
+  const row = (
     <ToolRow
       t={t}
       variant={model.variant}
@@ -60,4 +63,13 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
       inspect={inspect}
     />
   )
+  // 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} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
+    </div>
+  )
 }

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

@@ -0,0 +1,84 @@
+/**
+ * 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).
+ * - A web card whose `kind` this UI version does not know (a newer host's
+ *   value): the wire cannot be trusted to be `search` or `fetch`, so it takes
+ *   the generic path rather than rendering as a malformed fetch.
+ * @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,
+    }
+  }
+  // Discriminate `fetch` explicitly rather than treating it as the else of
+  // `search`: a `kind` this UI version does not know arrives over the wire from
+  // a newer host, and reading it as a fetch would draw an empty URL and
+  // `HTTP undefined`. It takes the generic path, the same wire-boundary default
+  // an unknown `card` tag takes above. The static union narrows `kind` to
+  // `'fetch'` here, but the runtime value is off the wire, so the guard and its
+  // null fallthrough are load-bearing despite the type.
+  // oxlint-disable-next-line typescript/no-unnecessary-condition
+  if (result.kind === 'fetch') {
+    return {
+      kind: 'fetch',
+      url: result.url,
+      statusCode: result.statusCode,
+      truncated: result.truncated,
+    }
+  }
+  return null
+}

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

+ 24 - 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 { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
+import { webCardModel } from '../contract/web-card-model.ts'
 import { resultText, 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.
  * @param props.t - the panel's locale seat, passed down as a plain prop.
@@ -148,6 +151,24 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
       </>
     )
   }
+  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. Below the card the
+  // panel also renders the flattened result content — the model-visible text
+  // the card does not carry verbatim (a web_fetch card shows only the URL and
+  // status, so its fetched body lives only here; a search card's answer and
+  // sources are structured, so the flattened form repeats them as the raw text
+  // the model saw).
+  if (web !== null) {
+    const settled = 'kind' in material.block ? material.block : null
+    const body = settled === null ? '' : resultText(settled)
+    return (
+      <>
+        <WebBlock {...web} className={css.web} />
+        {body !== '' && <pre className={css.code}>{body}</pre>}
+      </>
+    )
+  }
   // 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}>{t('details.running')}</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;
+}

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

@@ -0,0 +1,92 @@
+// 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} maxSources={CHAT_WEB_MAX_SOURCES} 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)
+  },
+}

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

@@ -84,12 +84,13 @@ describe('apply wiring', () => {
     await b.runtime.dispose()
   })
 
-  it('mounts the bash sample and the product rows as keyed entries through the load-order seam', async () => {
+  it('mounts the bash sample, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
     const b = await bench()
     // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
-    // service being present implies the chat entry declared the hole first.
+    // 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', 'ask_user_question'])
+    expect(entries.map(e => e.options.key)).toEqual(['bash', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
     // 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()

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

@@ -0,0 +1,270 @@
+// @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'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import { zh } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+const SID = 's1' as SessionId
+
+/** Locale seat for the card render sites (GenericToolCard, DetailsPanel), as the sibling suites build it. */
+const t = makeTranslate(zh, commonZh)
+
+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()
+    // A web card whose kind this UI version does not know (a newer host's
+    // value) also takes the generic path, not a malformed fetch.
+    const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView
+    expect(webCardModel(settledSearch({ resultView: futureKind }))).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')} t={t} />)
+    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')} t={t} />)
+    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()}
+        t={t}
+      />,
+    )
+  }
+
+  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 and keeps the fetched body below it', () => {
+    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()
+    // The card is a summary (URL + status only); the panel is the single-call
+    // reading surface, so the fetched body still renders below the card.
+    const output = view.getByText('输出').closest('section')
+    expect(output?.querySelector('pre')?.textContent).toContain('fetch body')
+  })
+
+  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('输出').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'])
+  })
+})

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 packages/client/ui-primitives/README.md
-README.md: 4075f0e7472141b5d41fe0f51c1a620eae913bfb
-README.zh.md: 7fd9529e597bc473a7c35fc3614f21f84cd19f44
+README.md: 5406d501eade2881491b3d157edddaa03f235a35
+README.zh.md: 18c7cefa7cdd631405f34f18e7c4c3369c65bc88

File diff suppressed because it is too large
+ 1 - 1
packages/client/ui-primitives/README.md


File diff suppressed because it is too large
+ 1 - 1
packages/client/ui-primitives/README.zh.md


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

@@ -0,0 +1,133 @@
+/* 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);
+}
+
+.expandItem {
+  list-style: none;
+}
+
+.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);
+}
+
+.empty {
+  color: var(--dsw-alias-label-secondary);
+  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;
+}

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

@@ -0,0 +1,242 @@
+// 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 http(s) subset of the
+// allowlist MarkdownText applies to untrusted assistant-authored links (it also
+// permits mailto, excluded here); 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
+  /**
+   * Accepted and ignored, so both card kinds take one uniform prop set (a fetch
+   * card has no source list to cap) — the same way TerminalBlock accepts one
+   * `maxLines` across its arms. Lets a render site spread `maxSources` onto
+   * either kind without a per-kind conditional.
+   */
+  maxSources?: number | undefined
+  /** 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. 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. This is the
+ * http(s) subset of the allowlist MarkdownText applies to untrusted links —
+ * MarkdownText also permits `mailto:`, deliberately excluded here since a
+ * retrieval URL is never a mail address.
+ * @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 OR parses
+ * to an empty hostname (a `file:`/`data:`/`javascript:` URL), so a label is
+ * never blank.
+ * @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 {
+    const { hostname } = new URL(url)
+    return hostname === '' ? 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. The
+ * `<li value>` pins the source's original 1-based position, so a collapsed list
+ * whose tail is drawn after the head still numbers each source by its real
+ * citation index rather than by its position in the visible subset.
+ * @param props.source - the source to render.
+ * @param props.ordinal - the source's 1-based position in the full list.
+ * @returns the source list item.
+ */
+function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: number }) {
+  return (
+    <li className={css.source} value={ordinal}>
+      <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) : []
+  // A provider may legitimately return no answer and no sources; the chat WebRow
+  // does not show the raw result content, so without this the user would see an
+  // empty card. Mirror the backend's `No results found.` render text.
+  const empty = (answer === undefined || answer === '') && sources.length === 0
+  return (
+    <div className={clsx(css.block, className)} data-web="search">
+      {answer !== undefined && answer !== '' && (
+        <div className={css.answer}><MarkdownText text={answer} /></div>
+      )}
+      {empty ? (
+        <div className={css.empty}>未找到结果</div>
+      ) : (
+        <ol className={css.sources}>
+          {head.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
+          {hidden > 0 && (
+            <li className={css.expandItem}>
+              <button
+                type="button"
+                className={css.expand}
+                aria-expanded={expanded}
+                aria-label={expanded ? '收起来源' : `展开其余 ${hidden} 条来源`}
+                onClick={onToggle}
+              >
+                {expanded ? '收起' : `… 其余 ${hidden} 条来源`}
+              </button>
+            </li>
+          )}
+          {tail.map((source, index) => (
+            <SourceItem
+              key={sources.length - tailCount + index}
+              source={source}
+              ordinal={sources.length - tailCount + index + 1}
+            />
+          ))}
+        </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

@@ -24,6 +24,8 @@ export { JsonTree } from './JsonTree.tsx'
 export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
 export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
 export type { TerminalBlockProps, TerminalBlockLabels } 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 type { CodeBlockProps } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'

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

@@ -0,0 +1,211 @@
+// @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('shows the empty-state note when a search returns no answer and no sources', () => {
+    const view = render(<WebBlock kind="search" sources={[]} truncated={false} />)
+    expect(view.getByText('未找到结果')).toBeTruthy()
+    // The empty note replaces the source list, not an empty <ol>.
+    expect(view.container.querySelector('ol')).toBeNull()
+  })
+
+  it('shows the source list, not the empty note, when a source is present', () => {
+    const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
+    expect(view.container.querySelector('ol')).toBeTruthy()
+    expect(view.queryByText('未找到结果')).toBeNull()
+  })
+
+  it('shows the source list when an empty source list still carries an answer', () => {
+    const view = render(<WebBlock kind="search" answer="Just an answer" sources={[]} truncated={false} />)
+    expect(view.getByText('Just an answer')).toBeTruthy()
+    expect(view.queryByText('未找到结果')).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('labels a source by the raw url when it parses to an empty hostname', () => {
+    // file:/data:/javascript: URLs parse but have no hostname; the label must
+    // fall back to the raw URL so it is never blank (and the link stays plain
+    // text since the protocol is not http(s)).
+    const view = render(<WebBlock kind="search" truncated={false} sources={[
+      { url: 'file:///etc/passwd' },
+    ]} />)
+    expect(view.getByText('file:///etc/passwd')).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('numbers a collapsed tail by each source original position, not its visible slot', () => {
+    // maxSources 4 over 10 sources: the tail is sources 8 and 9, which must read
+    // as citations 9 and 10 (via <li value>), not renumbered 3 and 4.
+    const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
+    const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
+    expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '9', '10'])
+  })
+
+  it('keeps the expander out of the ordered-list numbering', () => {
+    // The expander is a marker-less <li>, so it is valid inside <ol> and does not
+    // consume a citation number between the head and tail sources.
+    const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
+    const ol = view.container.querySelector('ol')!
+    // Every direct child is an <li> (no bare <button> child — invalid HTML).
+    expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
+  })
+
+  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()
+  })
+})

Some files were not shown because too many files changed in this diff