From f6802ee0192243a6f704d3ac51d3abeede9d8d63 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 17:43:13 +0800 Subject: [PATCH 01/11] feat(web): render web_search/web_fetch output as a web card Consume the card:'web' result view (structured sources + answer for search, the URL and HTTP status for fetch) the web backend PR added. WebBlock (ui-primitives) draws both kinds via the kind discriminant: a citation list of safe external links (the MarkdownText protocol allowlist, title-or-hostname label), a truncation indicator, a height cap matching TerminalBlock; a fetch summary for the other kind. web-card-model is the single resultView derivation; a keyed WebRow registers under web_search and web_fetch with the card resident under its summary. The generic fallback and the details panel are web-aware. Fixture gains web_search and web_fetch turns for the built-boot snapshot. --- ...6-07-30-web-result-card-frontend.i18n.yaml | 6 + .../2026-07-30-web-result-card-frontend.md | 49 ++++ .../2026-07-30-web-result-card-frontend.zh.md | 49 ++++ .../client/connection/src/client/fixture.ts | 79 +++++- .../ui-conversation/src/client/apply.ts | 6 + .../client/chat/GenericToolCard.module.css | 15 ++ .../src/client/chat/GenericToolCard.tsx | 19 +- .../src/client/contract/web-card-model.ts | 70 +++++ .../client/skeleton/DetailsPanel.module.css | 6 + .../src/client/skeleton/DetailsPanel.tsx | 13 +- .../src/client/toolviews/web-row.module.css | 95 +++++++ .../src/client/toolviews/web-row.tsx | 95 +++++++ .../ui-conversation/tests/chat-apply.spec.tsx | 9 +- .../ui-conversation/tests/web-card.spec.tsx | 255 ++++++++++++++++++ .../ui-primitives/src/WebBlock.module.css | 124 +++++++++ .../client/ui-primitives/src/WebBlock.tsx | 209 ++++++++++++++ packages/client/ui-primitives/src/index.ts | 2 + .../ui-primitives/tests/web-block.spec.tsx | 165 ++++++++++++ 18 files changed, 1256 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md create mode 100644 packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css create mode 100644 packages/client/ui-conversation/src/client/contract/web-card-model.ts create mode 100644 packages/client/ui-conversation/src/client/toolviews/web-row.module.css create mode 100644 packages/client/ui-conversation/src/client/toolviews/web-row.tsx create mode 100644 packages/client/ui-conversation/tests/web-card.spec.tsx create mode 100644 packages/client/ui-primitives/src/WebBlock.module.css create mode 100644 packages/client/ui-primitives/src/WebBlock.tsx create mode 100644 packages/client/ui-primitives/tests/web-block.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml new file mode 100644 index 0000000000..e2cb16b5cc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md +2026-07-30-web-result-card-frontend.md: cf2dd26a8f6eebe6d8d5d275a462151b7c3274a2 +2026-07-30-web-result-card-frontend.zh.md: 505243f7a31df1a5be0381a9399c5283272d1fe8 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md new file mode 100644 index 0000000000..cf2dd26a8f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md @@ -0,0 +1,49 @@ +# Agent Note: Web result card frontend — rendering the web render intent in the browser + +Status: implemented + +English | [中文](2026-07-30-web-result-card-frontend.zh.md) + +## Problem + +The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web result card](2026-07-30-web-result-card.md)): a `kind`-tagged union carrying either the structured cited sources plus an optional provider answer (`kind: 'search'`) or the fetched URL and its HTTP status (`kind: 'fetch'`). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: a completed web call rendered only as its flattened model-facing text, the same lossy render the contract note explains the structured view exists to replace. A `web_search` reached the reader as one free-text markdown line per source rather than a citation list of clickable sources, and a `web_fetch` as its markdown body with no retrieval summary. + +## Decision + +`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-conversation/src/client/contract/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), and for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves). + +One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant. + +**Links are safe by the same allowlist MarkdownText applies to untrusted assistant-authored links.** A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse. + +**Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock. + +The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance. + +## Consequences + +`WebBlock` reads only the web view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view, and unlike the terminal card it needs no cwd resolution because a web view carries no path. A UI without the `web` capability (the TUI) still gets the contract's fallback `content`; nothing about the tools' result shape changed. `MarkdownText` is reused for the answer, so the answer's own untrusted-link handling and GFM rendering come for free. + +A separate later PR unifies the whole-row collapse/expand interaction and will flip every resident card (terminal, diff, web) to expand-gated at once; this card follows the current resident convention rather than pre-empting that change. + +## Alternatives considered + +**Two components, one per kind.** Rejected: the two shapes share their card chrome, their safe-link handling, and their truncation indicator, and the contract already expresses their difference as a `kind` discriminant under one `card` tag; two components would duplicate the shared surface and split the safe-link logic. + +**Reparse the model-facing render text instead of consuming the structured view.** Rejected for the same reason the contract note gives: `web_search`'s render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover `{url, title?, snippet?, publishedAt?}`. The structured `resultView` is the only faithful source, which is why the backend PR added it. + +**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist matches MarkdownText's, so untrusted links behave identically wherever they render. + +## Testing + +`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap. + +`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it. + +The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server. + +## Related + +- [Web result card](2026-07-30-web-result-card.md) — the backend PR that added the `card: 'web'` result arm and made the two tools emit it; this is its deferred frontend consumer. +- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a `ui-primitives` block, a single card-model derivation, keyed and fallback chat rows, and a details-panel arm, for the `terminal` render intent. +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `web` arm. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md new file mode 100644 index 0000000000..505243f7a3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Web result 卡片前端 —— 在浏览器渲染 web 渲染意图 + +Status: implemented + +[English](2026-07-30-web-result-card-frontend.md) | 中文 + +## Problem + +`web_search` 和 `web_fetch` 工具声明了 `card: 'web'` result view([web result card](2026-07-30-web-result-card.md)):一个 `kind` 标签联合,携带结构化的被引用 sources 加可选的 provider answer(`kind: 'search'`),或抓取的 URL 及其 HTTP 状态(`kind: 'fetch'`)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `resultView` 投递到 `ConversationSnapshot` —— 但 Web 客户端忽略了它:一次已完成的 web 调用只渲染为摊平的模型可见文本,正是契约笔记所解释的、结构化视图要替代的那种有损渲染。`web_search` 到达读者时是每个 source 一行自由文本 markdown,而非可点击 source 的引用列表;`web_fetch` 是它的 markdown 正文,没有检索摘要。 + +## Decision + +`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、以及对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)。 + +一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。 + +**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用的同一 allowlist。** 一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。 + +**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。 + +卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片。 + +## Consequences + +`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到契约的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。 + +一条独立的后续 PR 会统一整行折叠/展开交互,并把每张常驻卡片(terminal、diff、web)一次性翻成 expand-gated;本卡片遵循当前的常驻约定,而非抢先做那次改动。 + +## Alternatives considered + +**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而契约已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。 + +**重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。 + +**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 与 MarkdownText 的一致,因此不受信任的链接无论在何处渲染都行为相同。 + +## Testing + +`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。 + +`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。 + +fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。 + +## Related + +- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。 +- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。 +- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cb79a6b9a2..0196713372 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -136,6 +136,54 @@ const TERMINAL_EXIT_STATUS: Record = { @@ -29,8 +31,9 @@ const VARIANT_ICONS: Record = { export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) { const model = toolRowModel(toolName, block, cwd) const terminal = terminalCardModel(block, cwd) + const web = webCardModel(block) const singleFile = model.filePath !== undefined - return ( + const row = ( ) + // 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 ( +
+ {row} + +
+ ) } diff --git a/packages/client/ui-conversation/src/client/contract/web-card-model.ts b/packages/client/ui-conversation/src/client/contract/web-card-model.ts new file mode 100644 index 0000000000..28eb3de2d2 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/web-card-model.ts @@ -0,0 +1,70 @@ +/** + * Pure derivation of the web-card props from a frozen call slice: the + * `card:'web'` render intent the `web_search`/`web_fetch` tools declare at + * result time arrives on the snapshot as `resultView`, and this is the one + * place that turns it into what {@link WebBlock} draws. Both conversation + * render sites (the chat tool row's resident/expanded body and the details + * panel's Output section) call this, so the sources and fetch summary they + * show are derived once. + * + * The web card is result-only by contract: those tools keep a generic pending + * call view, so there is nothing to derive while the call is still running and + * a running call always takes the generic path. + * @module + */ +import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallBlock } from './tool-call-model.ts' + +/** + * Sources the chat row's web body shows before collapsing the middle — half + * the primitive's own default, which the details panel keeps. A chat row is a + * summary surface inside the message flow: the flow must stay scannable across + * many calls, while the details panel is the single-call reading surface. A + * design constant of this UI's row geometry, not a deployment choice, so it is + * fixed here rather than a plugin Config field. + */ +export const CHAT_WEB_MAX_SOURCES = 8 + +/** + * Derive the web-card props for a tool call, or null when this call is not a + * web card and belongs on the generic path. + * + * The result side supplies the whole card: the sources and answer for a + * `search`, the URL and status for a `fetch`. Cases producing null, all of + * them the documented generic-card default: + * + * - A running call (no `resultView` yet): the web tools keep a generic pending + * card, so nothing web-shaped exists until the call settles. + * - A settled call whose result view is not a web card — including a `card` + * value this UI version does not know, which arrives over the wire and so + * cannot be trusted to be one of the compiled variants, and a generic result + * view (a web tool's error path returns the generic card, whose text the + * generic path preserves). + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the web-card props, or null for the generic path. + */ +export function webCardModel(block: ToolCallBlock): WebBlockProps | null { + // Running calls have no result view; the web card is result-only. + if (!('kind' in block)) return null + const result = block.resultView + if (result?.card !== 'web') return null + if (result.kind === 'search') { + return { + kind: 'search', + answer: result.answer, + sources: result.sources.map(source => ({ + url: source.url, + title: source.title, + snippet: source.snippet, + publishedAt: source.publishedAt, + })), + truncated: result.truncated, + } + } + return { + kind: 'fetch', + url: result.url, + statusCode: result.statusCode, + truncated: result.truncated, + } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index 143174fe42..b4e85912ab 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/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; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 9fc5a04ff6..d31717da44 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -7,11 +7,12 @@ // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. -import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { CodeBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' import { terminalCardModel } from '../contract/terminal-card-model.ts' +import { webCardModel } from '../contract/web-card-model.ts' import type { ToolCallBlock } from '../contract/tool-call-model.ts' import css from './DetailsPanel.module.css' @@ -127,8 +128,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo * The Output section's body for the selected call. A terminal-card call — a * shell command's call/result views — renders through the shared TerminalBlock * at the primitive's own full height allowance, so column-aligned output keeps - * its alignment and scrolls sideways instead of folding. Every other call, and - * a running call with no terminal card yet, keeps the flattened text form. + * its alignment and scrolls sideways instead of folding. A web-card call — a + * `web_search`/`web_fetch` result — renders through WebBlock at its own full + * source-list allowance. Every other call, and a running call with no card + * yet, keeps the flattened text form. * @param props.material - the selected call's material from {@link materialFor}. * @param props.cwd - the session workspace root, resolving the terminal view's cwd. * @returns the Output section's body element. @@ -147,6 +150,10 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u ) } + const web = webCardModel(material.block) + // Full source-list allowance here (the panel is the single-call reading + // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. + if (web !== null) return // 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
运行中…
diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.module.css b/packages/client/ui-conversation/src/client/toolviews/web-row.module.css new file mode 100644 index 0000000000..0b1519e218 --- /dev/null +++ b/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; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx new file mode 100644 index 0000000000..29a30776f9 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx @@ -0,0 +1,95 @@ +// Web toolview registrant: third-party posture over the keyed toolview hole +// (ctx.slots.register + ToolRowProps only — never imports the chat domain). +// Registered under BOTH web_search and web_fetch, since both declare the one +// `web` render intent and render through the one WebBlock family; the row +// discriminates on the toolName only to pick its icon and title. +// +// A web tool declares the `web` render intent at result time, so this row +// renders the completed retrieval through WebBlock resident below its summary, +// the same posture BashRow uses for the terminal card: no expand control on the +// row itself, not a details-panel target, and the block's own expander keeps a +// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is +// passed as maxSources — the chat flow's tighter cap over the block's default +// of 16). Until the call settles there is no web card (the tools keep a generic +// pending view), so a running row is the summary line alone. + +import type { Context } from 'cordis' +import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './web-row.module.css' + +/** web_fetch reads one URL; web_search queries. Titles are figma literals. */ +const WEB_TITLES: Record = { + 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 + case 'stopped': return + // Running keeps the icon — the row sweep carries the in-flight signal. + default: return toolName === 'web_fetch' ? : + } +} + +/** 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 ( +
+
+ {leadingFor(toolName, model.state)} + {status !== null && {status}} + {WEB_TITLES[toolName] ?? model.title} + + {model.summary} +
+ {web !== null && ( + + )} +
+ ) +} + +/** + * 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) + }, +} diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index d7b9125b34..42d1f648a1 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -80,12 +80,13 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the web rows, and the todo row as keyed entries through the load-order seam', async () => { const b = await bench() - // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the - // service being present implies the chat entry declared the hole first. + // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the + // service being present implies the chat entry declared the hole first. The + // web rows register one component under both web tool names. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'web_search', 'web_fetch', 'todo_write']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx new file mode 100644 index 0000000000..9ed25978f1 --- /dev/null +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -0,0 +1,255 @@ +// @vitest-environment jsdom +// The web render intent on the web side: the pure webCardModel derivation over +// resultView, and the conversation render sites that consume it — the keyed +// WebRow (registered under both web_search and web_fetch), the GenericToolCard +// render-site fallback, and the details panel's Output section. Mirrors +// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat +// row's resident card, the panel arm, and the keyed registration. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx' + +afterEach(cleanup) + +const SID = 's1' as SessionId + +const SEARCH_ARGS = '{"query":"deepseek harness"}' +const FETCH_ARGS = '{"url":"https://example.com/page"}' + +/** A web_search result view; overrides tune the sources / answer / truncation. */ +const resultSearch = (over?: Partial>): 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>): ToolResultView => ({ + card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over, +}) + +const runningSearch = (over?: Partial): 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 => ({ + 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 => ({ + kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2', + call: { name: 'web_fetch', argsRaw: FETCH_ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'fetch body' }], isError: false, + callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over, +}) + +describe('webCardModel', () => { + it('derives a search card from the result view, projecting every source field', () => { + expect(webCardModel(settledSearch())).toEqual({ + kind: 'search', + answer: 'A short answer.', + truncated: false, + sources: [ + { url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' }, + { url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined }, + ], + }) + }) + + it('carries the search truncation flag and an absent answer', () => { + const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } })) + expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] }) + }) + + it('derives a fetch card from the result view', () => { + expect(webCardModel(settledFetch())).toEqual({ + kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, + }) + expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) }))) + .toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true }) + }) + + it('returns null for a running call, since the web card is result-only', () => { + expect(webCardModel(runningSearch())).toBeNull() + // Even a running call that somehow carried a web call view stays generic: + // the derivation reads resultView only. + expect(webCardModel(runningSearch({ callView: null }))).toBeNull() + }) + + it('returns null for a settled call whose result view is not a web card', () => { + expect(webCardModel(settledSearch({ resultView: null }))).toBeNull() + expect(webCardModel(settledSearch({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView + expect(webCardModel(settledSearch({ resultView: future }))).toBeNull() + }) +}) + +describe('chat row web body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({ + callId: block.callId, toolName, block, openFile: vi.fn(), + }) + // WebRow reads only toolName/block off the full runtime share; the standard + // kit is unused, so the cast supplies the owner slice alone (as BashRow's + // tests do for the terminal card). + const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps => + ownerProps(block, toolName) as unknown as ToolRowProps + + it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => { + expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16) + const view = render() + // 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() + 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() + 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() + 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() + 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() + 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({ ids: [], byId: {}, current: undefined, phase: 'ready' }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + it('renders the search card at full source allowance', () => { + const view = mount(snapshot({ nodes: [settledSearch()] }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) + expect(view.getByText('Titled')).toBeTruthy() + expect(view.getByText('excerpt')).toBeTruthy() + // The Input JSON section survives beside it. + expect(view.getByText(/"query"/)).toBeTruthy() + }) + + it('renders the fetch card', () => { + const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' }) + const card = view.container.querySelector('[data-web="fetch"]') + expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page') + expect(view.getByText('HTTP 200')).toBeTruthy() + }) + + it('a non-web result keeps the flattened pre form', () => { + const view = mount(snapshot({ + nodes: [settledSearch({ callView: null, resultView: null })], + }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) + expect(view.container.querySelector('[data-web]')).toBeNull() + const output = view.getByText('Output').closest('section') + expect(output?.querySelector('pre')?.textContent).toContain('search text') + }) +}) + +describe('web toolview registration', () => { + it('registers one WebRow under both web_search and web_fetch', () => { + const registered: { key: string; component: unknown }[] = [] + const ctx = { + slots: { + register: (options: { name: string; key: string }, component: unknown) => { + registered.push({ key: options.key, component }) + return () => {} + }, + }, + } as unknown as import('cordis').Context + webToolview.apply(ctx) + expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch']) + // One component under both keys, not two thin rows. + expect(registered[0]?.component).toBe(WebRow) + expect(registered[1]?.component).toBe(WebRow) + // The load-order seam the render site depends on. + expect(webToolview.inject).toEqual(['slots', 'conversation']) + }) +}) diff --git a/packages/client/ui-primitives/src/WebBlock.module.css b/packages/client/ui-primitives/src/WebBlock.module.css new file mode 100644 index 0000000000..d595acb3cc --- /dev/null +++ b/packages/client/ui-primitives/src/WebBlock.module.css @@ -0,0 +1,124 @@ +/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface, + 16px vertical margin) so a web card, a terminal card, and a fenced code block + read as one family. A source list is prose, not aligned output, so it wraps + normally rather than scrolling horizontally like a terminal card's output. */ + +.block { + --dsl-web-radius: 12px; + + margin: 16px 0; + padding: 12px 14px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-markdown-code-block); + border-radius: var(--dsl-web-radius); +} + +/* The provider answer reads as body prose above the citation list; its own + MarkdownText margins are trimmed so the list sits tight under it. */ +.answer { + margin-bottom: 8px; +} + +.answer > :global(div) > :first-child { + margin-top: 0; +} + +.answer > :global(div) > :last-child { + margin-bottom: 0; +} + +/* The citation list: ordered so each source reads as a numbered reference. */ +.sources { + margin: 0; + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.source { + min-width: 0; +} + +.sourceLink { + color: var(--dsw-alias-state-business-primary); + font-size: 14px; + line-height: 20px; + word-break: break-word; +} + +.sourceLink:hover { + text-decoration: underline; +} + +.snippet { + margin-top: 2px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 19px; + word-break: break-word; +} + +.published { + margin-top: 2px; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); +} + +.expand { + display: block; + width: 100%; + padding: 0; + border: none; + background-color: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + font: inherit; + text-align: left; +} + +.expand:hover { + color: var(--dsw-alias-label-secondary); +} + +.truncated { + margin-top: 8px; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); +} + +/* The fetch card is a compact summary: the URL over a status/truncation row. */ +.fetch { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fetchUrl { + color: var(--dsw-alias-state-business-primary); + font-family: var(--ds-font-family-code); + font-size: 13px; + line-height: 19px; + word-break: break-all; +} + +.fetchUrl:hover { + text-decoration: underline; +} + +.fetchMeta { + display: flex; + align-items: baseline; + gap: 12px; +} + +.status { + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); +} + +/* The fetch card's truncation note sits inline beside the status, so it drops + the search card's top margin. */ +.fetch .truncated { + margin-top: 0; +} diff --git a/packages/client/ui-primitives/src/WebBlock.tsx b/packages/client/ui-primitives/src/WebBlock.tsx new file mode 100644 index 0000000000..d4757858a3 --- /dev/null +++ b/packages/client/ui-primitives/src/WebBlock.tsx @@ -0,0 +1,209 @@ +// WebBlock: the surface for a completed web retrieval. One component draws both +// kinds of the `web` render intent, discriminated by `kind`: a `search` shows an +// optional provider answer above a citation list of sources (each a safe +// external link labelled by its title, or its hostname when the provider gave +// none, with the snippet and publication date below it), and a `fetch` shows a +// compact retrieval summary (the linked final URL and its HTTP status). Both +// mark a capped retrieval. Every link is a same-origin-safe external anchor: +// only http(s) URLs become anchors (target/rel set), the same protocol allowlist +// MarkdownText applies to untrusted assistant-authored links; an unparseable or +// non-http URL renders as plain text. Geometry, radius, and fonts mirror +// CodeBlock/TerminalBlock so a web card reads as one family with them; a long +// source list caps at maxSources with a head/tail collapse using the same +// arithmetic as TerminalBlock's output cap. + +import { useCallback, useState } from 'react' +import clsx from 'clsx' +import { MarkdownText } from './markdown/MarkdownText.tsx' +import css from './WebBlock.module.css' + +/** + * Sources shown before the height cap collapses the middle of a citation list. + * Matches TerminalBlock's default output budget so both cards cut a long body + * at the same place; the chat row narrows it through the maxSources prop. + */ +export const DEFAULT_WEB_MAX_SOURCES = 16 + +/** + * One citeable source drawn in a search card: the projection of the contract's + * `WebSource`, with the optional fields kept optional so a provider that + * returned only a URL still renders (its hostname becomes the label). + */ +export interface WebSourceView { + /** The source URL; becomes a safe external link when it is http(s). */ + url: string + /** The source title; when absent the URL's hostname labels the link. */ + title?: string | undefined + /** A short excerpt or summary shown under the link. */ + snippet?: string | undefined + /** Publication/crawl timestamp, a provider-supplied string shown under the link. */ + publishedAt?: string | undefined +} + +/** A `web_search` card: an optional answer over a capped citation list. */ +export interface WebSearchBlockProps { + kind: 'search' + /** The provider-generated answer, rendered as markdown above the sources. */ + answer?: string | undefined + /** The cited sources, in provider order. */ + sources: WebSourceView[] + /** True when the tool cut the source list to its result cap. */ + truncated: boolean + /** Sources shown before the middle collapses (default {@link DEFAULT_WEB_MAX_SOURCES}). */ + maxSources?: number | undefined + /** Extra class merged onto the wrapper (callers position; this component draws). */ + className?: string | undefined +} + +/** A `web_fetch` card: the retrieval summary for one fetched URL. */ +export interface WebFetchBlockProps { + kind: 'fetch' + /** The final URL after allowed redirects; becomes a safe external link when http(s). */ + url: string + /** HTTP status code of the fetched response. */ + statusCode: number + /** True when the provider or the output cap cut the fetched content. */ + truncated: boolean + /** Extra class merged onto the wrapper (callers position; this component draws). */ + className?: string | undefined +} + +/** A completed web retrieval card, discriminated by `kind`. */ +export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps + +/** + * The URL to link to, or undefined when the URL must render as plain text. The + * allowlist is MarkdownText's own for untrusted links: only http(s) becomes a + * navigable external anchor, so a `javascript:`/`data:`/`file:` URL or an + * unparseable string never reaches the DOM as an href. + * @param url - the source or fetch URL, from tool result content. + * @returns the href to use, or undefined for plain text. + */ +function safeHref(url: string): string | undefined { + try { + const { protocol } = new URL(url) + return protocol === 'http:' || protocol === 'https:' ? url : undefined + } catch { + return undefined + } +} + +/** + * The link's visible label: the title when the provider gave one, otherwise the + * URL's hostname, falling back to the raw URL when it does not parse. + * @param url - the source URL. + * @param title - the provider title, if any. + * @returns the label text. + */ +function linkLabel(url: string, title: string | undefined): string { + if (title !== undefined && title !== '') return title + try { + return new URL(url).hostname + } catch { + return url + } +} + +/** + * A single URL rendered as a safe external anchor, or as plain text when the + * URL is not an http(s) link. + * @param props.url - the URL to render. + * @param props.label - the visible label. + * @param props.className - class for the anchor or the plain span. + * @returns the anchor or span element. + */ +function SafeLink({ url, label, className }: { url: string; label: string; className?: string | undefined }) { + const href = safeHref(url) + if (href === undefined) return {label} + return ( + + {label} + + ) +} + +/** + * One source row in a search card: the safe link plus its snippet and date. + * @param props.source - the source to render. + * @returns the source list item. + */ +function SourceItem({ source }: { source: WebSourceView }) { + return ( +
  • + + {source.snippet !== undefined && source.snippet !== '' && ( +
    {source.snippet}
    + )} + {source.publishedAt !== undefined && source.publishedAt !== '' && ( +
    {source.publishedAt}
    + )} +
  • + ) +} + +/** + * The search card body: the answer over the capped source list. + * @param props - see {@link WebSearchBlockProps}. + * @returns the search card element. + */ +function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_MAX_SOURCES, className }: WebSearchBlockProps) { + const [expanded, setExpanded] = useState(false) + const onToggle = useCallback(() => { setExpanded(value => !value) }, []) + const hidden = sources.length - maxSources + const capped = hidden > 0 && !expanded + // Same split arithmetic as TerminalBlock's output cap, so a long body's head + // and tail slices agree between the two cards. + const headCount = Math.ceil(maxSources / 2) + const tailCount = maxSources - headCount + const head = capped ? sources.slice(0, headCount) : sources + const tail = capped ? sources.slice(sources.length - tailCount) : [] + return ( +
    + {answer !== undefined && answer !== '' && ( +
    + )} +
      + {head.map((source, index) => )} + {hidden > 0 && ( + + )} + {tail.map((source, index) => )} +
    + {truncated &&
    来源列表已截断
    } +
    + ) +} + +/** + * 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 ( +
    + +
    + HTTP {statusCode} + {truncated && 内容已截断} +
    +
    + ) +} + +/** + * 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' ? : +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index aa674f7a1a..148199142e 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps } from './TerminalBlock.tsx' +export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx' +export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' diff --git a/packages/client/ui-primitives/tests/web-block.spec.tsx b/packages/client/ui-primitives/tests/web-block.spec.tsx new file mode 100644 index 0000000000..0c39e8b13d --- /dev/null +++ b/packages/client/ui-primitives/tests/web-block.spec.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +// WebBlock: both kinds of the web card. The search card's answer, its citation +// list with the title-or-hostname label fallback and optional snippet/date, the +// source-list height cap and its expand control, and the truncated indicator; +// the fetch card's linked URL, status, and truncation. Safe-link attributes on +// both kinds: an http(s) URL becomes an external anchor (target/rel), any other +// URL renders as plain text with no href. + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts' +import type { WebSourceView } from '../src/index.ts' + +afterEach(cleanup) + +/** `count` sources with sequential hostnames, so the cap slices read distinctly. */ +function sources(count: number): WebSourceView[] { + return Array.from({ length: count }, (_value, index) => ({ + url: `https://site-${index}.example.com/page`, + title: `Source ${index}`, + })) +} + +describe('WebBlock search card', () => { + it('renders the answer above the citation list', () => { + const view = render() + 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() + expect(view.container.querySelector('[class^="_answer_"]')).toBeNull() + const empty = render() + expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull() + }) + + it('labels a source by its title, and by hostname when the title is absent', () => { + const view = render() + expect(view.getByText('Titled')).toBeTruthy() + // No title / empty title: the hostname labels the link. + expect(view.getByText('plain.example.org')).toBeTruthy() + expect(view.getByText('empty.example.net')).toBeTruthy() + }) + + it('renders a source as a safe external anchor for an http(s) url', () => { + const view = render() + 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() + 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() + 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() + expect(on.getByText('来源列表已截断')).toBeTruthy() + cleanup() + const off = render() + expect(off.queryByText('来源列表已截断')).toBeNull() + }) + + it('renders every source and no expand control under the cap', () => { + const view = render() + 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() + // maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden. + expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)) + .toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9']) + const toggle = view.getByRole('button', { name: '展开其余 6 条来源' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toBe('… 其余 6 条来源') + + fireEvent.click(toggle) + expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10) + const collapse = view.getByRole('button', { name: '收起来源' }) + expect(collapse.getAttribute('aria-expanded')).toBe('true') + expect(collapse.textContent).toBe('收起') + + fireEvent.click(collapse) + expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4) + }) + + it('renders the head slice alone when the cap leaves no tail', () => { + const view = render() + 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() + 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() + 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() + 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() + expect(on.getByText('内容已截断')).toBeTruthy() + cleanup() + const off = render() + expect(off.queryByText('内容已截断')).toBeNull() + }) + + it('carries a non-200 status verbatim', () => { + const view = render() + expect(view.getByText('HTTP 404')).toBeTruthy() + }) +}) From 17ee269f115a3fc3fc9c5028d1549e8681b87d66 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 19:36:17 +0800 Subject: [PATCH 02/11] =?UTF-8?q?fix(web):=20address=20web=20card=20review?= =?UTF-8?q?=20=E2=80=94=20panel=20body,=20ol=20numbering,=20safe=20links,?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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
  • , and the expand control is a marker-less
  • so the
      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. --- ...6-07-30-web-result-card-frontend.i18n.yaml | 4 +- .../2026-07-30-web-result-card-frontend.md | 4 +- .../2026-07-30-web-result-card-frontend.zh.md | 4 +- .../client/connection/src/client/fixture.ts | 5 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/chat/GenericToolCard.tsx | 5 +- .../src/client/skeleton/DetailsPanel.tsx | 16 +++- .../src/client/toolviews/web-row.tsx | 5 +- .../ui-conversation/tests/web-card.spec.tsx | 6 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../ui-primitives/src/WebBlock.module.css | 4 + .../client/ui-primitives/src/WebBlock.tsx | 77 ++++++++++++------- .../ui-primitives/tests/web-block.spec.tsx | 27 +++++++ 17 files changed, 132 insertions(+), 53 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml index e2cb16b5cc..db354e75a3 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md index cf2dd26a8f..efead1f404 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md index 505243f7a3..2e76bf4c18 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md +++ b/.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 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0196713372..7c81d702b7 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/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**.', diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6ca5e5fa30..447c2b58ea 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 855c42b337..bc28e9acb7 100644 --- a/packages/client/ui-conversation/README.md +++ b/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: '', 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). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 31cf2c7b5a..1fea2ee8aa 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/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: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 5c63896825..56d239b88f 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -56,10 +56,7 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner return (
      {row} - +
      ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index d31717da44..57c5d63460 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/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 + // 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 ( + <> + + {body !== '' &&
      {body}
      } + + ) + } // 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
      运行中…
      diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx index 29a30776f9..b86c523a26 100644 --- a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx @@ -66,10 +66,7 @@ export function WebRow({ toolName, block }: ToolRowProps) { {model.summary} {web !== null && ( - + )} ) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index 9ed25978f1..d6f04eb147 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/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', () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b5e4b5c078..2a64292653 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 0ef3c20f84..b5b79f7f0d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), and TerminalBlock. Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, and WebBlock. Contract: api-contracts v3 §8. ## Markdown rendering @@ -12,6 +12,10 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ `TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +## Web retrieval + +`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `
    1. `, and the expand control is a marker-less `
    2. ` so the `
        ` stays valid HTML. A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md). + ## Model Experience None, as the package renders pure React atoms in the browser; nothing here reaches a model request. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index af94551bfb..b101834964 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock。契约:api-contracts v3 §8。 +纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock 与 WebBlock。契约:api-contracts v3 §8。 ## Markdown 渲染 @@ -11,6 +11,10 @@ `TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 +## Web 检索 + +`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind(由 `kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer(通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL)时回退到原始 URL,因此标签绝不为空;其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16,即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `
      1. ` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `
      2. `,使 `
          ` 保持为合法 HTML。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。 + ## 模型体验 无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 diff --git a/packages/client/ui-primitives/src/WebBlock.module.css b/packages/client/ui-primitives/src/WebBlock.module.css index d595acb3cc..17383563bf 100644 --- a/packages/client/ui-primitives/src/WebBlock.module.css +++ b/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%; diff --git a/packages/client/ui-primitives/src/WebBlock.tsx b/packages/client/ui-primitives/src/WebBlock.tsx index d4757858a3..f0144bd601 100644 --- a/packages/client/ui-primitives/src/WebBlock.tsx +++ b/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 + * `
        1. ` 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 ( -
        2. +
        3. {source.snippet !== undefined && source.snippet !== '' && (
          {source.snippet}
          @@ -163,19 +180,27 @@ function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_M
          )}
            - {head.map((source, index) => )} + {head.map((source, index) => )} {hidden > 0 && ( - +
          1. + +
          2. )} - {tail.map((source, index) => )} + {tail.map((source, index) => ( + + ))}
          {truncated &&
          来源列表已截断
          } diff --git a/packages/client/ui-primitives/tests/web-block.spec.tsx b/packages/client/ui-primitives/tests/web-block.spec.tsx index 0c39e8b13d..b0778a636a 100644 --- a/packages/client/ui-primitives/tests/web-block.spec.tsx +++ b/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() + expect(view.getByText('file:///etc/passwd')).toBeTruthy() + }) + it('renders a source as a safe external anchor for an http(s) url', () => { const view = render( { 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
        4. ), not renumbered 3 and 4. + const view = render() + 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
        5. , so it is valid inside
            and does not + // consume a citation number between the head and tail sources. + const view = render() + const ol = view.container.querySelector('ol')! + // Every direct child is an
          1. (no bare -
          2. - )} - {tail.map((source, index) => ( - - ))} -
          + {empty ? ( +
          未找到结果
          + ) : ( +
            + {head.map((source, index) => )} + {hidden > 0 && ( +
          1. + +
          2. + )} + {tail.map((source, index) => ( + + ))} +
          + )} {truncated &&
          来源列表已截断
          } ) diff --git a/packages/client/ui-primitives/tests/web-block.spec.tsx b/packages/client/ui-primitives/tests/web-block.spec.tsx index b0778a636a..d681ce753b 100644 --- a/packages/client/ui-primitives/tests/web-block.spec.tsx +++ b/packages/client/ui-primitives/tests/web-block.spec.tsx @@ -36,6 +36,25 @@ describe('WebBlock search card', () => { 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() + expect(view.getByText('未找到结果')).toBeTruthy() + // The empty note replaces the source list, not an empty
            . + expect(view.container.querySelector('ol')).toBeNull() + }) + + it('shows the source list, not the empty note, when a source is present', () => { + const view = render() + 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() + 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( Date: Fri, 31 Jul 2026 10:43:51 +0800 Subject: [PATCH 07/11] docs: re-record ui-conversation README pairing after base merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1bdb6eac8e..5095b30cef 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/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: 84671fe067917f4aa38bccfaa413404652456fc2 -README.zh.md: 7aea71890bb725c7a87d9fb12804d9477f4eed7a +README.md: 0e7a5629052112aac3aed832767c20e1096e097f +README.zh.md: d687aea5c46251e6374a293cf87e12101c2540b8 From bf26cfd2ff44ce788ebf4a74b22bae6a4a596929 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 10:51:58 +0800 Subject: [PATCH 08/11] docs: re-record ui README pairings after master merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.i18n.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 5095b30cef..9caf61378b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/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: 0e7a5629052112aac3aed832767c20e1096e097f -README.zh.md: d687aea5c46251e6374a293cf87e12101c2540b8 +README.md: f1fdd233b1dcef80e8d4abb263570d4bdf586c7a +README.zh.md: 3e182439e6cbb5df6ef70fc8b188c784f19e57e5 diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a93e506412..c665d9a754 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/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: 4e1d85cd830fc720a3abf54b0700ea16789404ae -README.zh.md: 8b8771fa6ec714ba5c4a780135d98109f6c4d186 +README.md: 5406d501eade2881491b3d157edddaa03f235a35 +README.zh.md: 18c7cefa7cdd631405f34f18e7c4c3369c65bc88 From 452ba583d2ba533a37f5e4086333bc7c9f7191b4 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 10:54:36 +0800 Subject: [PATCH 09/11] test(client-ui-conversation): pass locale seat to web-card GenericToolCard/DetailsPanel renders master's locale rollout made `t` a required prop on GenericToolCardProps and the details slot; the web-card spec predates it. Supply the makeTranslate seat the sibling suites use. --- .../client/ui-conversation/tests/web-card.spec.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index e92612c810..69df3e5fbf 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -20,11 +20,17 @@ 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"}' @@ -164,7 +170,7 @@ describe('chat row web body', () => { // card is resident there too. const view = render() + }), 'fx-web')} t={t} />) expect(view.getByText('Titled')).toBeTruthy() expect(view.container.querySelector('[data-web="search"]')).not.toBeNull() }) @@ -172,7 +178,7 @@ describe('chat row web body', () => { it('the GenericToolCard fallback keeps the plain row for a non-web call', () => { const view = render() + }), 'echo')} t={t} />) expect(view.container.querySelector('[data-web]')).toBeNull() }) }) @@ -199,6 +205,7 @@ describe('DetailsPanel web Output section', () => { useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} + t={t} />, ) } From 2cad75a2175b417726320c210e97fe45a9af9cb8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 10:59:43 +0800 Subject: [PATCH 10/11] =?UTF-8?q?test(client-ui-conversation):=20assert=20?= =?UTF-8?q?localized=20=E8=BE=93=E5=87=BA=20section=20title=20in=20web-car?= =?UTF-8?q?d=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit master's locale rollout renders the details Output section title through t('details.output') (输出 under the zh seat the test uses). The spec still asserted the English 'Output' literal and failed. Match the sibling terminal-card suite's localized assertion. --- packages/client/ui-conversation/tests/web-card.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index 69df3e5fbf..cea95e61f6 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -234,7 +234,7 @@ describe('DetailsPanel web Output section', () => { 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') + const output = view.getByText('输出').closest('section') expect(output?.querySelector('pre')?.textContent).toContain('fetch body') }) @@ -243,7 +243,7 @@ describe('DetailsPanel web Output section', () => { nodes: [settledSearch({ callView: null, resultView: null })], }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) expect(view.container.querySelector('[data-web]')).toBeNull() - const output = view.getByText('Output').closest('section') + const output = view.getByText('输出').closest('section') expect(output?.querySelector('pre')?.textContent).toContain('search text') }) }) From 6f0405ed842f61297a0296b9000bc043f868762c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 11:27:13 +0800 Subject: [PATCH 11/11] docs: re-record ui-conversation README pairing after master merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 9caf61378b..cfbff2117e 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/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: f1fdd233b1dcef80e8d4abb263570d4bdf586c7a -README.zh.md: 3e182439e6cbb5df6ef70fc8b188c784f19e57e5 +README.md: 64440a3d69d78871bdc4777f88bb65c71b02fb69 +README.zh.md: dde2d38d9fae102dbfd49e9c5cacf16385ef3ef8