Bläddra i källkod

fix(web): address web card review — panel body, ol numbering, safe links, docs

- DetailsPanel: render the flattened result content below the web card, so a
  web_fetch's fetched body (and a search's answer/source markdown) stays
  visible on the panel's single-call reading surface — the card is a summary.
- WebBlock: the collapsed source tail keeps each source's original citation
  number via <li value>, and the expand control is a marker-less <li> so the
  <ol> is valid HTML; an empty-hostname URL (file:/data:) falls back to the raw
  URL so a label is never blank.
- web-row / GenericToolCard: both spread WebBlock uniformly with maxSources
  (fetch ignores it, like TerminalBlock's maxLines), dropping the duplicated
  per-kind conditional.
- Docs: WebBlock added to the ui-primitives README (both languages) with a Web
  retrieval section; the ui-conversation README's "inline licensed for this
  intent alone" claim de-absolutized and a web-card paragraph added; the Agent
  Note's safe-link description corrected to the http(s) subset of MarkdownText's
  allowlist (mailto excluded). Fixture source comment aligned with its data.
- Tests: ol numbering + marker-less expander, empty-hostname label fallback,
  the fetched body visible in the panel.
Chinesezjc 2 månader sedan
förälder
incheckning
17ee269f11

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.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 .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
-2026-07-30-web-result-card-frontend.md: cf2dd26a8f6eebe6d8d5d275a462151b7c3274a2
-2026-07-30-web-result-card-frontend.zh.md: 505243f7a31df1a5be0381a9399c5283272d1fe8
+2026-07-30-web-result-card-frontend.md: efead1f404879c526475a1b9f0b31f6c34978f07
+2026-07-30-web-result-card-frontend.zh.md: 2e76bf4c18f5032cd5fd9b15fe31d3c7b036b502

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

@@ -14,7 +14,7 @@ The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web
 
 One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant.
 
-**Links are safe by the same allowlist MarkdownText applies to untrusted assistant-authored links.** A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse.
+**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.
 
@@ -32,7 +32,7 @@ A separate later PR unifies the whole-row collapse/expand interaction and will f
 
 **Reparse the model-facing render text instead of consuming the structured view.** Rejected for the same reason the contract note gives: `web_search`'s render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover `{url, title?, snippet?, publishedAt?}`. The structured `resultView` is the only faithful source, which is why the backend PR added it.
 
-**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist matches MarkdownText's, so untrusted links behave identically wherever they render.
+**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
 

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

@@ -14,7 +14,7 @@ Status: implemented
 
 一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。
 
-**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用的同一 allowlist。** 一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。
+**链接的安全性沿用 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 唯一刻意的分歧。
 
@@ -32,7 +32,7 @@ Status: implemented
 
 **重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。
 
-**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 与 MarkdownText 的一致,因此不受信任的链接无论在何处渲染都行为相同。
+**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还允许 `mailto:`)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。
 
 ## Testing
 

+ 3 - 2
packages/client/connection/src/client/fixture.ts

@@ -152,8 +152,9 @@ interface WebSourceFixture {
  * The structured `web_search` result view for fixture turn 66, authored inline
  * because this client-side fixture cannot import the web tool that projects it.
  * The sources exercise the citation list's features: a titled source with a
- * snippet and a date, a source with no title (its hostname labels the link), and
- * a source with a snippet but no date. `truncated` marks the capped indicator.
+ * 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.
  */
 const WEB_SEARCH_RESULT: { answer: string; sources: WebSourceFixture[]; truncated: boolean } = {
   answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',

+ 2 - 2
packages/client/ui-conversation/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-conversation/README.md
-README.md: 855c42b3377e80b0d8f21a418da0a591782439e1
-README.zh.md: 31cf2c7b5a9a0740c2be9079ce55d897d175a6d0
+README.md: bc28e9acb7771a0276ee161ea507916d0d45edeb
+README.zh.md: 1fea2ee8aaee3e327c689df400a50ab58a61481a

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

@@ -12,7 +12,9 @@ Approvals take over the composer through the chain this package declares: `Appro
 
 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, or a `card` tag this client version does not know. 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. 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

@@ -10,7 +10,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` 标签,它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它。行的上限是 `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 会拒绝没有任何渲染方的声明)。
 

+ 1 - 4
packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx

@@ -56,10 +56,7 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
   return (
     <div className={css.card}>
       {row}
-      <WebBlock
-        {...(web.kind === 'search' ? { ...web, maxSources: CHAT_WEB_MAX_SOURCES } : web)}
-        className={css.web}
-      />
+      <WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
     </div>
   )
 }

+ 14 - 2
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx

@@ -152,8 +152,20 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
   }
   const web = webCardModel(material.block)
   // Full source-list allowance here (the panel is the single-call reading
-  // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES.
-  if (web !== null) return <WebBlock {...web} className={css.web} />
+  // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. The card is a
+  // summary — a web_fetch card shows only the URL and status — so the details
+  // panel also renders the flattened result content below it (the fetched body,
+  // the search answer + source markdown), which the card does not carry.
+  if (web !== null) {
+    const settled = 'kind' in material.block ? material.block : null
+    const body = settled === null ? '' : renderResult(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}>运行中…</div>

+ 1 - 4
packages/client/ui-conversation/src/client/toolviews/web-row.tsx

@@ -66,10 +66,7 @@ export function WebRow({ toolName, block }: ToolRowProps) {
         <span className={css.summary}>{model.summary}</span>
       </div>
       {web !== null && (
-        <WebBlock
-          {...(web.kind === 'search' ? { ...web, maxSources: CHAT_WEB_MAX_SOURCES } : web)}
-          className={css.web}
-        />
+        <WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
       )}
     </div>
   )

+ 5 - 1
packages/client/ui-conversation/tests/web-card.spec.tsx

@@ -216,11 +216,15 @@ describe('DetailsPanel web Output section', () => {
     expect(view.getByText(/"query"/)).toBeTruthy()
   })
 
-  it('renders the fetch card', () => {
+  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('Output').closest('section')
+    expect(output?.querySelector('pre')?.textContent).toContain('fetch body')
   })
 
   it('a non-web result keeps the flattened pre form', () => {

+ 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: 0ef3c20f848b3d331c007911d0837f11cd72c024
-README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299
+README.md: b5b79f7f0d01afb2d06fb18bf30131cbdda75ca2
+README.zh.md: b10183496479249346da5da808f5ab3b6d2eef67

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1 - 1
packages/client/ui-primitives/README.md


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1 - 1
packages/client/ui-primitives/README.zh.md


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

@@ -65,6 +65,10 @@
   font: var(--dsw-font-xs-13);
 }
 
+.expandItem {
+  list-style: none;
+}
+
 .expand {
   display: block;
   width: 100%;

+ 51 - 26
packages/client/ui-primitives/src/WebBlock.tsx

@@ -5,12 +5,13 @@
 // none, with the snippet and publication date below it), and a `fetch` shows a
 // compact retrieval summary (the linked final URL and its HTTP status). Both
 // mark a capped retrieval. Every link is a same-origin-safe external anchor:
-// only http(s) URLs become anchors (target/rel set), the same protocol allowlist
-// MarkdownText applies to untrusted assistant-authored links; an unparseable or
-// non-http URL renders as plain text. Geometry, radius, and fonts mirror
-// CodeBlock/TerminalBlock so a web card reads as one family with them; a long
-// source list caps at maxSources with a head/tail collapse using the same
-// arithmetic as TerminalBlock's output cap.
+// 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'
@@ -64,6 +65,13 @@ export interface WebFetchBlockProps {
   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
 }
@@ -72,10 +80,12 @@ export interface WebFetchBlockProps {
 export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps
 
 /**
- * The URL to link to, or undefined when the URL must render as plain text. The
- * allowlist is MarkdownText's own for untrusted links: only http(s) becomes a
- * navigable external anchor, so a `javascript:`/`data:`/`file:` URL or an
- * unparseable string never reaches the DOM as an href.
+ * 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.
  */
@@ -90,7 +100,9 @@ function safeHref(url: string): string | 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.
+ * 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.
@@ -98,7 +110,8 @@ function safeHref(url: string): string | undefined {
 function linkLabel(url: string, title: string | undefined): string {
   if (title !== undefined && title !== '') return title
   try {
-    return new URL(url).hostname
+    const { hostname } = new URL(url)
+    return hostname === '' ? url : hostname
   } catch {
     return url
   }
@@ -123,13 +136,17 @@ function SafeLink({ url, label, className }: { url: string; label: string; class
 }
 
 /**
- * One source row in a search card: the safe link plus its snippet and date.
+ * 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 }: { source: WebSourceView }) {
+function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: number }) {
   return (
-    <li className={css.source}>
+    <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>
@@ -163,19 +180,27 @@ function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_M
         <div className={css.answer}><MarkdownText text={answer} /></div>
       )}
       <ol className={css.sources}>
-        {head.map((source, index) => <SourceItem key={index} source={source} />)}
+        {head.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
         {hidden > 0 && (
-          <button
-            type="button"
-            className={css.expand}
-            aria-expanded={expanded}
-            aria-label={expanded ? '收起来源' : `展开其余 ${hidden} 条来源`}
-            onClick={onToggle}
-          >
-            {expanded ? '收起' : `… 其余 ${hidden} 条来源`}
-          </button>
+          <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} />)}
+        {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>

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

@@ -48,6 +48,16 @@ describe('WebBlock search card', () => {
     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' },
@@ -119,6 +129,23 @@ describe('WebBlock search card', () => {
     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'])

Vissa filer visades inte eftersom för många filer har ändrats