diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml index fa54d657a9..3c04e9b255 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.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/architecture/2026-07-30-settings-write-path-integrity.md -2026-07-30-settings-write-path-integrity.md: 07bd095162879c8e7866846cf562f6a13307e5fc -2026-07-30-settings-write-path-integrity.zh.md: 5d02177073d482b61750d7bdfbbd0866bc227a6a +2026-07-30-settings-write-path-integrity.md: 7bd50adc8a812759c3ae3f80a50978d75baa712a +2026-07-30-settings-write-path-integrity.zh.md: da07745ef1de1b694fc3fd1ee3d04322cdadc992 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md index 07bd095162..7bd50adc8a 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md @@ -14,7 +14,7 @@ Review found the provider's write path could destroy state it never observed, an **One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active. -**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff, a 2 s acquisition deadline, and stale takeover after 5 s (a crashed holder, broken with a warning). Readers never lock — the rename commit is atomic — so contention is writer-only and resolves in milliseconds. The lock constants are protocol invariants, not config: a holder rewrites one small document, so the deadline and stale age derive from that bound, not from deployment taste. +**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config. **Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is. @@ -24,7 +24,7 @@ Review found the provider's write path could destroy state it never observed, an ## Alternatives considered -- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is ~40 lines with deterministic tests (including injected `EEXIST`/`stat` races). The policy favors dependencies that delete owned code; this one would replace 40 explained lines with an opaque peer. +- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its ownership and retry policy is broader than this one-file protocol needs, and the shipped lock is a small exclusive-create loop with deterministic contention tests. The policy favors dependencies that delete owned code; this one would replace a narrow protocol with an opaque peer. - **Revision/CAS instead of a lock** — rename cannot express compare-and-swap, so a CAS needs a version sidecar or content re-hash and a retry loop in every writer; the lock achieves the same serialization with one primitive and keeps readers free. - **Merging external edits into the in-flight write's own section** — the seam merges patches over the state visible at call time, so a same-namespace external edit racing a write still resolves last-write-wins; folding it in would need three-way merge semantics no consumer has asked for. The write publishes the external state first, so the loser is at least observed before being superseded. - **Declaring async `settings/updated` listeners unsupported** — the typed signature is `void` and lint flags misused promises, but an unlinted JS plugin can still register an async listener; a contract note cannot un-throw an unhandled rejection, so containment is the only defense that holds at runtime. @@ -32,4 +32,4 @@ Review found the provider's write path could destroy state it never observed, an ## Consequences -`update()` gained a documented failure mode (lock deadline, invalid on-disk document) and the rejection messages carry `$`-rooted paths. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up. +`update()` has documented failure modes for the lock deadline and an invalid on-disk document, and rejection messages carry `$`-rooted paths. A crashed holder can leave a lock that requires verified operator removal; automatic age-based takeover would permit overlapping writers. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up. diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md index 5d02177073..da07745ef1 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md @@ -18,7 +18,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 **单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。 -**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行:指数退避、2 s 获取截止时间、5 s 后陈旧接管(持有者已崩溃;打破旧锁时给出告警)。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间,毫秒级即可化解。锁的各项常量是协议不变式,不是配置:持有者只是重写一份小文档,截止时间与陈旧时限都从这一上界推得,而非出自部署偏好。 +**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。 **观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件契约现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。 @@ -28,7 +28,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 ## 曾考虑的替代方案 -- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁约 40 行并带确定性测试(含注入的 `EEXIST`/`stat` 竞态)。该政策偏向能删除自有代码的依赖;这个依赖只会把 40 行带解释的代码换成一个不透明的等价物。 +- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其所有权与重试策略比这个单文件协议所需的更宽泛,而已交付的锁只是一个小型独占创建循环,带确定性的竞争测试。该政策偏向能删除自有代码的依赖;这个依赖只会把一个窄协议换成不透明的等价物。 - **用修订号/CAS 取代锁**——rename 表达不了 compare-and-swap,因此 CAS 需要一个版本伴随文件或内容重哈希,外加每个写方里的一个重试循环;锁用一个原语实现同样的串行化,还让读方完全免锁。 - **把外部编辑合并进正在进行的写入自身的分节**——seam 是在调用时刻可见的状态之上合并 patch 的,因此与写入竞态的同 namespace 外部编辑仍按后写胜出解决;要把外部编辑并进来,需要三方合并语义,而没有任何消费方提出过这种需求。写入会先发布外部状态,落败一方至少在被取代之前被观察到。 - **宣布不支持异步 `settings/updated` 监听器**——类型签名是 `void`,lint 也会标记误用的 promise,但未经 lint 的 JS 插件仍能注册异步监听器;契约里的一句说明无法收回已经抛出的 unhandled rejection,收容是唯一在运行时守得住的防线。 @@ -36,6 +36,6 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 ## 后果 -`update()` 有了成文的失败模式(锁截止时间到期、磁盘文档非法),rejection 消息携带以 `$` 为根的路径。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。 +`update()` 对锁获取期限与磁盘文档非法都有成文的失败模式,rejection 消息携带以 `$` 为根的路径。持有者崩溃后可能留下锁,需要操作者核实后移除;若按锁龄自动接管,则会允许多个写入方重叠。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。 [用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包(package)的那些 PR(Pull Request)所有,向上合并时按本模板处理。 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..dae8f18b4d --- /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: d6f4785e83335ca2dd5295516baf47c845ebf5bd +2026-07-30-web-result-card-frontend.zh.md: ed95cbe39f4f0bf77ba5da64d664705a0841863f 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..d6f4785e83 --- /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), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`). + +One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant. + +**Links are safe by the http(s) subset of the allowlist MarkdownText applies to untrusted assistant-authored links** — MarkdownText also permits `mailto:`, deliberately excluded here since a retrieval URL is never a mail address. A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:`/`mailto:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse. + +**Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock. + +The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance and, below it, the flattened model-visible result content: a `web_fetch` card carries only the URL and status, so its fetched body is readable only here. + +## Consequences + +`WebBlock` reads only the web view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view, and unlike the terminal card it needs no cwd resolution because a web view carries no path. A UI without the `web` capability (the TUI) still gets the contract's fallback `content`; nothing about the tools' result shape changed. `MarkdownText` is reused for the answer, so the answer's own untrusted-link handling and GFM rendering come for free. + +A separate later PR unifies the whole-row collapse/expand interaction and will flip every resident card (terminal, diff, web) to expand-gated at once; this card follows the current resident convention rather than pre-empting that change. + +## Alternatives considered + +**Two components, one per kind.** Rejected: the two shapes share their card chrome, their safe-link handling, and their truncation indicator, and the contract already expresses their difference as a `kind` discriminant under one `card` tag; two components would duplicate the shared surface and split the safe-link logic. + +**Reparse the model-facing render text instead of consuming the structured view.** Rejected for the same reason the contract note gives: `web_search`'s render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover `{url, title?, snippet?, publishedAt?}`. The structured `resultView` is the only faithful source, which is why the backend PR added it. + +**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist is the http(s) subset of MarkdownText's (which also permits `mailto:`), so untrusted retrieval links behave identically wherever they render. + +## Testing + +`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap. + +`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it. + +The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server. + +## Related + +- [Web result card](2026-07-30-web-result-card.md) — the backend PR that added the `card: 'web'` result arm and made the two tools emit it; this is its deferred frontend consumer. +- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a `ui-primitives` block, a single card-model derivation, keyed and fallback chat rows, and a details-panel arm, for the `terminal` render intent. +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `web` arm. 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..ed95cbe39f --- /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` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。 + +一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。 + +**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集。** MarkdownText 还允许 `mailto:`,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:`/`mailto:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。 + +**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。 + +卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。 + +## Consequences + +`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到契约的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。 + +一条独立的后续 PR 会统一整行折叠/展开交互,并把每张常驻卡片(terminal、diff、web)一次性翻成 expand-gated;本卡片遵循当前的常驻约定,而非抢先做那次改动。 + +## Alternatives considered + +**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而契约已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。 + +**重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。 + +**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还允许 `mailto:`)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。 + +## Testing + +`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。 + +`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。 + +fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。 + +## Related + +- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。 +- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。 +- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index edb52601f4..6fa5aec1ab 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -122,6 +122,18 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const footers = diffCards.map(card => card.textContent ?? '') expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true) + // The web render intent reaches the assembled boot graph: the fixture's + // web_search / web_fetch turns render their keyed WebRow cards, proving the + // registration, wire projection, and card rendering survive the real bundle + // path (not just the per-package src benches). The selector pins the KEYED + // WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute + // WebBlock draws — the generic fallback renders the same WebBlock, so a silent + // keyed-registration failure would still satisfy a bare `[data-web]` check. + await waitFor(() => { + expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull() + expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull() + }, { timeout: 10_000 }) + // Every bundle injected its plugin-owned style tag (the loader's CSS path). const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')] .map(style => style.getAttribute('data-plugin')) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 4152b3eda8..75766736f0 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -137,6 +137,44 @@ const TERMINAL_EXIT_STATUS: Record, 'card' | 'kind'> = { + answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.', + sources: [ + { + url: 'https://github.com/deepseek-ai/deepseek-harness', + title: 'DeepSeek Harness — plugin-based agent harness', + snippet: 'Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.', + publishedAt: '2026-07-01', + }, + { + url: 'https://www.deepseek.com/blog/harness-architecture', + snippet: 'The capability-seam pattern splits each capability into interface, implementation, and consumer packages.', + }, + { + url: 'https://docs.deepseek.com/harness/plugins', + title: 'Writing a harness plugin', + publishedAt: '2026-06-15', + }, + ], + truncated: true, +} + +/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */ +const WEB_FETCH_RESULT: Omit, 'card' | 'kind'> = { + url: 'https://www.deepseek.com/blog/harness-architecture', + statusCode: 200, + truncated: false, +} + const DEEPSEEK_REASONING = { efforts: [ { id: 'off', name: 'Off' }, @@ -333,8 +371,20 @@ function buildAlphaLog(): SessionEvent[] { // strip empty and take the todo surfaces' own coverage with it. toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) + // Turns 66-67: the web render intent — a web_search whose result view carries + // structured sources plus an answer (the citation list, one source lacking a + // title so its hostname labels the link, the capped indicator on), and a + // web_fetch whose result view carries the fetched URL and its HTTP status. + // Both keep a generic pending call view and add the `web` card only at + // result time, which is the contract's result-only web shape. Named after + // the real tools so they hit the keyed WebRow registration. Ordered BEFORE + // the todo turn for the same reason turn 65 is: the standing plan retires at + // the next turn/start, so a turn after it would empty the dock's plan strip. + toolTurn(66, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') + toolTurn(67, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') + const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). @@ -390,6 +440,13 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { card: 'diff', title: `Write ${str(args.file_path)}`, diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }], } + // The web tools keep a GENERIC pending card and add the `web` result card + // only at result time (the contract's result-only web shape); their pending + // kind matches the result kind so a call and its result read as one category. + case 'web_search': + return { card: 'generic', title: `Search ${str(args.query)}`, kind: 'search', rawInput: args } + case 'web_fetch': + return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args } default: return undefined // echo et al: the documented no-view fallback path } @@ -398,6 +455,17 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined { const call = presentCall(name, argsRaw) if (call === undefined) return undefined + // The web tools keep a generic pending card, so their result card is chosen + // by tool name rather than by the pending card tag: the structured `web` card + // the frontend consumes. The view carries no `content` copy (per the contract + // and the web-result-card note); a capability-less UI falls back to the raw + // `tool/result` content, which this fixture emits from `resultText`. + if (name === 'web_search') { + return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT } + } + if (name === 'web_fetch') { + return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT } + } switch (call.card) { case 'terminal': // The sample's own exit status, authored beside it: re-parsing the diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a96e6e6e1b..920c998060 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: 159aecca8a741195ee7272c4f788a5aefbe1507d -README.zh.md: 401a7ea1639895d0647fa17b2b44cea26a669abe +README.md: d8f369231de1341450049b2f0cf1acc01d42b9ad +README.zh.md: 49275390c30993e659afdda6aa14ef196b7fa859 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 159aecca8a..d8f369231d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,7 +14,9 @@ Logged non-user messages render as a default-collapsed `上下文注入` disclos Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. -A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). +A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). + +A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)). A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 401a7ea163..49275390c3 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,9 @@ 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 -声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 +声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 + +声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。 声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 30e726cab8..0be0f4f534 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -21,6 +21,7 @@ import { ChatView } from './chat/ChatView.tsx' import { StatsLine } from './chat/StatsLine.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' import { fileMutationToolview } from './toolviews/file-mutation-row.tsx' +import { webToolview } from './toolviews/web-row.tsx' import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { todoToolview } from './toolviews/todo-row.tsx' import { askQuestionToolview } from './toolviews/ask-question-row.tsx' @@ -323,6 +324,10 @@ export function apply(ctx: Context): void { // diff render intent, so these rows stack the applied diff card under their // path-link summary (the terminal card's posture, applied to diffs). ctx.plugin(fileMutationToolview) + // The web rows ride the same seam: one WebRow registered under both + // web_search and web_fetch, rendering the completed retrieval's web card + // resident under the summary (a product registration, not a sample). + ctx.plugin(webToolview) // The todo_write row rides the same seam (a product registration, not a sample). ctx.plugin(todoToolview) diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css new file mode 100644 index 0000000000..13a4ef9d62 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css @@ -0,0 +1,15 @@ +/* The generic card grows a resident web card under its summary row when the + tool declares the `web` render intent but has no keyed row of its own (the + web_search/web_fetch rows register their own WebRow). A column around the + ToolRow keeps the row's own 24px height. */ + +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.web { + margin: 4px 0 4px 22px; +} diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index dd8a8386f0..faadcf090b 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -7,13 +7,15 @@ import type { ReactNode } from 'react' import { IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16, - IconThinkOutline14, + IconThinkOutline14, WebBlock, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts' import { diffCardModel } from '../contract/diff-card-model.ts' import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts' import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts' import { ToolRow } from './ToolRow.tsx' +import css from './GenericToolCard.module.css' /** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */ const VARIANT_ICONS: Record = { @@ -36,13 +38,14 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: const model = toolRowModel(toolName, block, cwd) const terminal = terminalCardModel(block, cwd) const diff = diffCardModel(block) + const web = webCardModel(block) // A failing exit status is the terminal card's own error signal (the call // itself settles isError:false), surfaced as the row's red state dot. const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal) ? 'error' : model.state const singleFile = model.filePath !== undefined - return ( + const row = ( ) + // 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..f2b15e023a --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/web-card-model.ts @@ -0,0 +1,84 @@ +/** + * Pure derivation of the web-card props from a frozen call slice: the + * `card:'web'` render intent the `web_search`/`web_fetch` tools declare at + * result time arrives on the snapshot as `resultView`, and this is the one + * place that turns it into what {@link WebBlock} draws. Both conversation + * render sites (the chat tool row's resident/expanded body and the details + * panel's Output section) call this, so the sources and fetch summary they + * show are derived once. + * + * The web card is result-only by contract: those tools keep a generic pending + * call view, so there is nothing to derive while the call is still running and + * a running call always takes the generic path. + * @module + */ +import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallBlock } from './tool-call-model.ts' + +/** + * Sources the chat row's web body shows before collapsing the middle — half + * the primitive's own default, which the details panel keeps. A chat row is a + * summary surface inside the message flow: the flow must stay scannable across + * many calls, while the details panel is the single-call reading surface. A + * design constant of this UI's row geometry, not a deployment choice, so it is + * fixed here rather than a plugin Config field. + */ +export const CHAT_WEB_MAX_SOURCES = 8 + +/** + * Derive the web-card props for a tool call, or null when this call is not a + * web card and belongs on the generic path. + * + * The result side supplies the whole card: the sources and answer for a + * `search`, the URL and status for a `fetch`. Cases producing null, all of + * them the documented generic-card default: + * + * - A running call (no `resultView` yet): the web tools keep a generic pending + * card, so nothing web-shaped exists until the call settles. + * - A settled call whose result view is not a web card — including a `card` + * value this UI version does not know, which arrives over the wire and so + * cannot be trusted to be one of the compiled variants, and a generic result + * view (a web tool's error path returns the generic card, whose text the + * generic path preserves). + * - A web card whose `kind` this UI version does not know (a newer host's + * value): the wire cannot be trusted to be `search` or `fetch`, so it takes + * the generic path rather than rendering as a malformed fetch. + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the web-card props, or null for the generic path. + */ +export function webCardModel(block: ToolCallBlock): WebBlockProps | null { + // Running calls have no result view; the web card is result-only. + if (!('kind' in block)) return null + const result = block.resultView + if (result?.card !== 'web') return null + if (result.kind === 'search') { + return { + kind: 'search', + answer: result.answer, + sources: result.sources.map(source => ({ + url: source.url, + title: source.title, + snippet: source.snippet, + publishedAt: source.publishedAt, + })), + truncated: result.truncated, + } + } + // Discriminate `fetch` explicitly rather than treating it as the else of + // `search`: a `kind` this UI version does not know arrives over the wire from + // a newer host, and reading it as a fetch would draw an empty URL and + // `HTTP undefined`. It takes the generic path, the same wire-boundary default + // an unknown `card` tag takes above. The static union narrows `kind` to + // `'fetch'` here, but the runtime value is off the wire, so the guard and its + // null fallthrough are load-bearing despite the type. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (result.kind === 'fetch') { + return { + kind: 'fetch', + url: result.url, + statusCode: result.statusCode, + truncated: result.truncated, + } + } + return null +} 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 994ae718cb..4694715fe1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -107,3 +107,9 @@ .cardBody { 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 850033bbca..9002ef609e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -7,12 +7,13 @@ // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. -import { CodeBlock, DiffBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { CodeBlock, DiffBlock, 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 { diffCardModel } from '../contract/diff-card-model.ts' import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts' +import { webCardModel } from '../contract/web-card-model.ts' import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts' import css from './DetailsPanel.module.css' @@ -130,8 +131,9 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo * at the primitive's own full height allowance, so column-aligned output keeps * its alignment and scrolls sideways instead of folding. A diff-card call — a * write/edit's applied change — renders through the shared DiffBlock at the same - * full height. Every other call, and a running call with neither card yet, keeps - * the flattened text form. + * full height. A web-card call — a `web_search`/`web_fetch` result — renders + * through WebBlock at its own full source-list allowance. Every other call, and + * a running call with no card yet, keeps the flattened text form. * @param props.material - the selected call's material from {@link materialFor}. * @param props.cwd - the session workspace root, resolving the terminal view's cwd. * @param props.t - the panel's locale seat, passed down as a plain prop. @@ -153,6 +155,24 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string } const diff = diffCardModel(material.block) if (diff !== null) return + const web = webCardModel(material.block) + // Full source-list allowance here (the panel is the single-call reading + // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the + // panel also renders the flattened result content — the model-visible text + // the card does not carry verbatim (a web_fetch card shows only the URL and + // status, so its fetched body lives only here; a search card's answer and + // sources are structured, so the flattened form repeats them as the raw text + // the model saw). + if (web !== null) { + const settled = 'kind' in material.block ? material.block : null + const body = settled === null ? '' : resultText(settled) + return ( + <> + + {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
{t('details.running')}
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..b86c523a26 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx @@ -0,0 +1,92 @@ +// Web toolview registrant: third-party posture over the keyed toolview hole +// (ctx.slots.register + ToolRowProps only — never imports the chat domain). +// Registered under BOTH web_search and web_fetch, since both declare the one +// `web` render intent and render through the one WebBlock family; the row +// discriminates on the toolName only to pick its icon and title. +// +// A web tool declares the `web` render intent at result time, so this row +// renders the completed retrieval through WebBlock resident below its summary, +// the same posture BashRow uses for the terminal card: no expand control on the +// row itself, not a details-panel target, and the block's own expander keeps a +// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is +// passed as maxSources — the chat flow's tighter cap over the block's default +// of 16). Until the call settles there is no web card (the tools keep a generic +// pending view), so a running row is the summary line alone. + +import type { Context } from 'cordis' +import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './web-row.module.css' + +/** web_fetch reads one URL; web_search queries. Titles are figma literals. */ +const WEB_TITLES: Record = { + 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 5cb144e160..239f690998 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -84,13 +84,14 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample, the file-mutation rows, and the product rows as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => { const b = await bench() // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the // service being present implies the chat entry declared the hole first. The - // file-mutation registrant claims both write and edit for the diff card. + // file-mutation registrant claims both write and edit for the diff card; 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', 'edit', 'write', 'todo_write', 'ask_user_question']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() 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..cea95e61f6 --- /dev/null +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -0,0 +1,270 @@ +// @vitest-environment jsdom +// The web render intent on the web side: the pure webCardModel derivation over +// resultView, and the conversation render sites that consume it — the keyed +// WebRow (registered under both web_search and web_fetch), the GenericToolCard +// render-site fallback, and the details panel's Output section. Mirrors +// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat +// row's resident card, the panel arm, and the keyed registration. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const SID = 's1' as SessionId + +/** Locale seat for the card render sites (GenericToolCard, DetailsPanel), as the sibling suites build it. */ +const t = makeTranslate(zh, commonZh) + +const SEARCH_ARGS = '{"query":"deepseek harness"}' +const FETCH_ARGS = '{"url":"https://example.com/page"}' + +/** A web_search result view; overrides tune the sources / answer / truncation. */ +const resultSearch = (over?: Partial>): 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() + // A web card whose kind this UI version does not know (a newer host's + // value) also takes the generic path, not a malformed fetch. + const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView + expect(webCardModel(settledSearch({ resultView: futureKind }))).toBeNull() + }) +}) + +describe('chat row web body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({ + callId: block.callId, toolName, block, openFile: vi.fn(), + }) + // WebRow reads only toolName/block off the full runtime share; the standard + // kit is unused, so the cast supplies the owner slice alone (as BashRow's + // tests do for the terminal card). + const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps => + ownerProps(block, toolName) as unknown as ToolRowProps + + it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => { + expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16) + const view = render() + // 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()} + t={t} + />, + ) + } + + 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 and keeps the fetched body below it', () => { + const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' }) + const card = view.container.querySelector('[data-web="fetch"]') + expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page') + expect(view.getByText('HTTP 200')).toBeTruthy() + // The card is a summary (URL + status only); the panel is the single-call + // reading surface, so the fetched body still renders below the card. + const output = view.getByText('输出').closest('section') + expect(output?.querySelector('pre')?.textContent).toContain('fetch body') + }) + + it('a non-web result keeps the flattened pre form', () => { + const view = mount(snapshot({ + nodes: [settledSearch({ callView: null, resultView: null })], + }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) + expect(view.container.querySelector('[data-web]')).toBeNull() + const output = view.getByText('输出').closest('section') + expect(output?.querySelector('pre')?.textContent).toContain('search text') + }) +}) + +describe('web toolview registration', () => { + it('registers one WebRow under both web_search and web_fetch', () => { + const registered: { key: string; component: unknown }[] = [] + const ctx = { + slots: { + register: (options: { name: string; key: string }, component: unknown) => { + registered.push({ key: options.key, component }) + return () => {} + }, + }, + } as unknown as import('cordis').Context + webToolview.apply(ctx) + expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch']) + // One component under both keys, not two thin rows. + expect(registered[0]?.component).toBe(WebRow) + expect(registered[1]?.component).toBe(WebRow) + // The load-order seam the render site depends on. + expect(webToolview.inject).toEqual(['slots', 'conversation']) + }) +}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 3a03f925c0..d55fe45007 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: f0c30a2122b77aa6c1739e82d5bc6e81cc012d4e -README.zh.md: 2de0177be47e2bee3507e8dc1c8abc3d34e4344f +README.md: 58be01d56a85c66a144df3f8054840961e987403 +README.zh.md: 2efbec77e64d664553e93b5a8f8dcd2ec7fce49e diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index f0c30a2122..58be01d56a 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), TerminalBlock, and DiffBlock. 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, DiffBlock, and WebBlock. Contract: api-contracts v3 §8. ## Markdown rendering @@ -16,6 +16,10 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ `DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-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 `
  • `, and the expand control is a marker-less `
  • ` so the `
      ` stays valid HTML. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `
        ` (the chat row does not surface the raw result content). 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. @@ -29,5 +33,5 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. -- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. +- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2de0177be4..2efbec77e6 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,以及 DiffBlock。契约:api-contracts v3 §8。 +纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock,以及 WebBlock。契约:api-contracts v3 §8。 ## Markdown 渲染 @@ -15,6 +15,10 @@ `DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `,error token)在新增行(`+ `,success token)之上、同文件第二个 hunk 前一个 `⋯` gap,以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16,与 `TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap),使多文件复制保持可归属,并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock`。`+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-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。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `
            `(chat 行不呈现原始 result content)。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。 + ## 模型体验 无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 @@ -28,5 +32,5 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 -- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。 +- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 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..8aad5872ba --- /dev/null +++ b/packages/client/ui-primitives/src/WebBlock.module.css @@ -0,0 +1,133 @@ +/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface, + 16px vertical margin) so a web card, a terminal card, and a fenced code block + read as one family. A source list is prose, not aligned output, so it wraps + normally rather than scrolling horizontally like a terminal card's output. */ + +.block { + --dsl-web-radius: 12px; + + margin: 16px 0; + padding: 12px 14px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-markdown-code-block); + border-radius: var(--dsl-web-radius); +} + +/* The provider answer reads as body prose above the citation list; its own + MarkdownText margins are trimmed so the list sits tight under it. */ +.answer { + margin-bottom: 8px; +} + +.answer > :global(div) > :first-child { + margin-top: 0; +} + +.answer > :global(div) > :last-child { + margin-bottom: 0; +} + +/* The citation list: ordered so each source reads as a numbered reference. */ +.sources { + margin: 0; + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.source { + min-width: 0; +} + +.sourceLink { + color: var(--dsw-alias-state-business-primary); + font-size: 14px; + line-height: 20px; + word-break: break-word; +} + +.sourceLink:hover { + text-decoration: underline; +} + +.snippet { + margin-top: 2px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 19px; + word-break: break-word; +} + +.published { + margin-top: 2px; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); +} + +.expandItem { + list-style: none; +} + +.expand { + display: block; + width: 100%; + padding: 0; + border: none; + background-color: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + font: inherit; + text-align: left; +} + +.expand:hover { + color: var(--dsw-alias-label-secondary); +} + +.truncated { + margin-top: 8px; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); +} + +.empty { + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); +} + +/* The fetch card is a compact summary: the URL over a status/truncation row. */ +.fetch { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fetchUrl { + color: var(--dsw-alias-state-business-primary); + font-family: var(--ds-font-family-code); + font-size: 13px; + line-height: 19px; + word-break: break-all; +} + +.fetchUrl:hover { + text-decoration: underline; +} + +.fetchMeta { + display: flex; + align-items: baseline; + gap: 12px; +} + +.status { + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); +} + +/* The fetch card's truncation note sits inline beside the status, so it drops + the search card's top margin. */ +.fetch .truncated { + margin-top: 0; +} diff --git a/packages/client/ui-primitives/src/WebBlock.tsx b/packages/client/ui-primitives/src/WebBlock.tsx new file mode 100644 index 0000000000..804ff17dfa --- /dev/null +++ b/packages/client/ui-primitives/src/WebBlock.tsx @@ -0,0 +1,242 @@ +// WebBlock: the surface for a completed web retrieval. One component draws both +// kinds of the `web` render intent, discriminated by `kind`: a `search` shows an +// optional provider answer above a citation list of sources (each a safe +// external link labelled by its title, or its hostname when the provider gave +// none, with the snippet and publication date below it), and a `fetch` shows a +// compact retrieval summary (the linked final URL and its HTTP status). Both +// mark a capped retrieval. Every link is a same-origin-safe external anchor: +// only http(s) URLs become anchors (target/rel set) — the http(s) subset of the +// allowlist MarkdownText applies to untrusted assistant-authored links (it also +// permits mailto, excluded here); an unparseable or non-http URL renders as +// plain text. Geometry, radius, and fonts mirror CodeBlock/TerminalBlock so a +// web card reads as one family with them; a long source list caps at maxSources +// with a head/tail collapse using the same arithmetic as TerminalBlock's output +// cap. + +import { useCallback, useState } from 'react' +import clsx from 'clsx' +import { MarkdownText } from './markdown/MarkdownText.tsx' +import css from './WebBlock.module.css' + +/** + * Sources shown before the height cap collapses the middle of a citation list. + * Matches TerminalBlock's default output budget so both cards cut a long body + * at the same place; the chat row narrows it through the maxSources prop. + */ +export const DEFAULT_WEB_MAX_SOURCES = 16 + +/** + * One citeable source drawn in a search card: the projection of the contract's + * `WebSource`, with the optional fields kept optional so a provider that + * returned only a URL still renders (its hostname becomes the label). + */ +export interface WebSourceView { + /** The source URL; becomes a safe external link when it is http(s). */ + url: string + /** The source title; when absent the URL's hostname labels the link. */ + title?: string | undefined + /** A short excerpt or summary shown under the link. */ + snippet?: string | undefined + /** Publication/crawl timestamp, a provider-supplied string shown under the link. */ + publishedAt?: string | undefined +} + +/** A `web_search` card: an optional answer over a capped citation list. */ +export interface WebSearchBlockProps { + kind: 'search' + /** The provider-generated answer, rendered as markdown above the sources. */ + answer?: string | undefined + /** The cited sources, in provider order. */ + sources: WebSourceView[] + /** True when the tool cut the source list to its result cap. */ + truncated: boolean + /** Sources shown before the middle collapses (default {@link DEFAULT_WEB_MAX_SOURCES}). */ + maxSources?: number | undefined + /** Extra class merged onto the wrapper (callers position; this component draws). */ + className?: string | undefined +} + +/** A `web_fetch` card: the retrieval summary for one fetched URL. */ +export interface WebFetchBlockProps { + kind: 'fetch' + /** The final URL after allowed redirects; becomes a safe external link when http(s). */ + url: string + /** HTTP status code of the fetched response. */ + statusCode: number + /** True when the provider or the output cap cut the fetched content. */ + truncated: boolean + /** + * Accepted and ignored, so both card kinds take one uniform prop set (a fetch + * card has no source list to cap) — the same way TerminalBlock accepts one + * `maxLines` across its arms. Lets a render site spread `maxSources` onto + * either kind without a per-kind conditional. + */ + maxSources?: number | undefined + /** Extra class merged onto the wrapper (callers position; this component draws). */ + className?: string | undefined +} + +/** A completed web retrieval card, discriminated by `kind`. */ +export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps + +/** + * The URL to link to, or undefined when the URL must render as plain text. Only + * http(s) becomes a navigable external anchor, so a `javascript:`/`data:`/`file:` + * URL or an unparseable string never reaches the DOM as an href. This is the + * http(s) subset of the allowlist MarkdownText applies to untrusted links — + * MarkdownText also permits `mailto:`, deliberately excluded here since a + * retrieval URL is never a mail address. + * @param url - the source or fetch URL, from tool result content. + * @returns the href to use, or undefined for plain text. + */ +function safeHref(url: string): string | undefined { + try { + const { protocol } = new URL(url) + return protocol === 'http:' || protocol === 'https:' ? url : undefined + } catch { + return undefined + } +} + +/** + * The link's visible label: the title when the provider gave one, otherwise the + * URL's hostname, falling back to the raw URL when it does not parse OR parses + * to an empty hostname (a `file:`/`data:`/`javascript:` URL), so a label is + * never blank. + * @param url - the source URL. + * @param title - the provider title, if any. + * @returns the label text. + */ +function linkLabel(url: string, title: string | undefined): string { + if (title !== undefined && title !== '') return title + try { + const { hostname } = new URL(url) + return hostname === '' ? url : hostname + } catch { + return url + } +} + +/** + * A single URL rendered as a safe external anchor, or as plain text when the + * URL is not an http(s) link. + * @param props.url - the URL to render. + * @param props.label - the visible label. + * @param props.className - class for the anchor or the plain span. + * @returns the anchor or span element. + */ +function SafeLink({ url, label, className }: { url: string; label: string; className?: string | undefined }) { + const href = safeHref(url) + if (href === undefined) return {label} + return ( + + {label} + + ) +} + +/** + * 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, ordinal }: { source: WebSourceView; ordinal: number }) { + return ( +
          2. + + {source.snippet !== undefined && source.snippet !== '' && ( +
            {source.snippet}
            + )} + {source.publishedAt !== undefined && source.publishedAt !== '' && ( +
            {source.publishedAt}
            + )} +
          3. + ) +} + +/** + * The search card body: the answer over the capped source list. + * @param props - see {@link WebSearchBlockProps}. + * @returns the search card element. + */ +function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_MAX_SOURCES, className }: WebSearchBlockProps) { + const [expanded, setExpanded] = useState(false) + const onToggle = useCallback(() => { setExpanded(value => !value) }, []) + const hidden = sources.length - maxSources + const capped = hidden > 0 && !expanded + // Same split arithmetic as TerminalBlock's output cap, so a long body's head + // and tail slices agree between the two cards. + const headCount = Math.ceil(maxSources / 2) + const tailCount = maxSources - headCount + const head = capped ? sources.slice(0, headCount) : sources + const tail = capped ? sources.slice(sources.length - tailCount) : [] + // A provider may legitimately return no answer and no sources; the chat WebRow + // does not show the raw result content, so without this the user would see an + // empty card. Mirror the backend's `No results found.` render text. + const empty = (answer === undefined || answer === '') && sources.length === 0 + return ( +
            + {answer !== undefined && answer !== '' && ( +
            + )} + {empty ? ( +
            未找到结果
            + ) : ( +
              + {head.map((source, index) => )} + {hidden > 0 && ( +
            1. + +
            2. + )} + {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 d21635a6d0..a854901b88 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -24,6 +24,8 @@ export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx' export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx' export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx' +export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx' +export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' 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..d681ce753b --- /dev/null +++ b/packages/client/ui-primitives/tests/web-block.spec.tsx @@ -0,0 +1,211 @@ +// @vitest-environment jsdom +// WebBlock: both kinds of the web card. The search card's answer, its citation +// list with the title-or-hostname label fallback and optional snippet/date, the +// source-list height cap and its expand control, and the truncated indicator; +// the fetch card's linked URL, status, and truncation. Safe-link attributes on +// both kinds: an http(s) URL becomes an external anchor (target/rel), any other +// URL renders as plain text with no href. + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts' +import type { WebSourceView } from '../src/index.ts' + +afterEach(cleanup) + +/** `count` sources with sequential hostnames, so the cap slices read distinctly. */ +function sources(count: number): WebSourceView[] { + return Array.from({ length: count }, (_value, index) => ({ + url: `https://site-${index}.example.com/page`, + title: `Source ${index}`, + })) +} + +describe('WebBlock search card', () => { + it('renders the answer above the citation list', () => { + const view = render() + 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('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() + expect(view.getByText('Titled')).toBeTruthy() + // No title / empty title: the hostname labels the link. + expect(view.getByText('plain.example.org')).toBeTruthy() + expect(view.getByText('empty.example.net')).toBeTruthy() + }) + + it('labels a source by the raw url when it parses to an empty hostname', () => { + // file:/data:/javascript: URLs parse but have no hostname; the label must + // fall back to the raw URL so it is never blank (and the link stays plain + // text since the protocol is not http(s)). + const view = render() + expect(view.getByText('file:///etc/passwd')).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('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
            1. ), 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
            2. , 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