Merge remote-tracking branch 'origin/master' into feat/web-diff-card

# Conflicts:
#	apps/web/tests/built-boot.snapshot.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
#	packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
#	packages/client/ui-conversation/tests/chat-apply.spec.tsx
#	packages/client/ui-primitives/README.i18n.yaml
#	packages/client/ui-primitives/README.md
#	packages/client/ui-primitives/README.zh.md
#	packages/client/ui-primitives/src/index.ts
This commit is contained in:
Chinesezjc
2026-07-31 11:56:28 +08:00
40 changed files with 1448 additions and 161 deletions
@@ -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
@@ -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 `<file>.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 `<file>.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.
@@ -18,7 +18,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删
**单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。
**写入持有以 `wx` 创建的同目录 `<file>.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行指数退避2 s 获取截止时间、5 s 后陈旧接管(持有者已崩溃;打破旧锁时给出告警)。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间,毫秒级即可化解。锁的各项常量是协议不变式,不是配置:持有者只是重写一份小文档,截止时间与陈旧时限都从这一上界推得,而非出自部署偏好
**写入持有以 `wx` 创建的同目录 `<file>.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)的那些 PRPull Request)所有,向上合并时按本模板处理。
@@ -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
@@ -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.
@@ -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 viewweb 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。
一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。
**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集。** MarkdownText 还允许 `mailto:`,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 `http:``https:` 时才成为可导航锚点,带 `target="_blank"``rel="noopener noreferrer"``javascript:`/`data:`/`file:`/`mailto:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。
**几何镜像 CodeBlock/TerminalBlock**12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。
卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search``web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。
## Consequences
`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到契约的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。
一条独立的后续 PR 会统一整行折叠/展开交互,并把每张常驻卡片(terminal、diff、web)一次性翻成 expand-gated;本卡片遵循当前的常驻约定,而非抢先做那次改动。
## Alternatives considered
**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而契约已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。
**重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。
**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还允许 `mailto:`)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。
## Testing
`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span;snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。
`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`;键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search``web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`,因此覆盖率运行不度量它。
fixture`packages/client/connection/src/client/fixture.ts`)添加 turn 66`web_search`)与 67`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。
## Related
- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。
- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。
- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。
+12
View File
@@ -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'))
@@ -137,6 +137,44 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
}
/**
* The structured `web_search` result view for fixture turn 66, authored inline
* because this client-side fixture cannot import the web tool that projects it.
* The sources exercise the citation list's features: a titled source with a
* snippet and a date, a source with no title (its hostname labels the link) and
* a snippet but no date, and a source with a title and a date but no snippet.
* `truncated` marks the capped indicator. The shape is the contract's own
* search view minus its wire discriminants.
*/
const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'search' }>, 'card' | 'kind'> = {
answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',
sources: [
{
url: 'https://github.com/deepseek-ai/deepseek-harness',
title: 'DeepSeek Harness — plugin-based agent harness',
snippet: 'Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.',
publishedAt: '2026-07-01',
},
{
url: 'https://www.deepseek.com/blog/harness-architecture',
snippet: 'The capability-seam pattern splits each capability into interface, implementation, and consumer packages.',
},
{
url: 'https://docs.deepseek.com/harness/plugins',
title: 'Writing a harness plugin',
publishedAt: '2026-06-15',
},
],
truncated: true,
}
/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */
const WEB_FETCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind'> = {
url: 'https://www.deepseek.com/blog/harness-architecture',
statusCode: 200,
truncated: false,
}
const DEEPSEEK_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
@@ -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
@@ -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
+3 -1
View File
@@ -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)).
+3 -1
View File
@@ -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 viewwrite/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write``edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`8),面板为 16[决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
@@ -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)
@@ -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;
}
@@ -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<ToolRowVariant, ReactNode> = {
@@ -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 = (
<ToolRow
t={t}
variant={model.variant}
@@ -66,4 +69,13 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
inspect={inspect}
/>
)
// A web-declaring tool without its own keyed row lands here; its card is
// resident under the summary, mirroring WebRow (and BashRow's terminal card).
if (web === null) return row
return (
<div className={css.card}>
{row}
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
</div>
)
}
@@ -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
}
@@ -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;
}
@@ -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 <DiffBlock {...diff.card} className={css.cardBody} />
const web = webCardModel(material.block)
// Full source-list allowance here (the panel is the single-call reading
// surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the
// panel also renders the flattened result content — the model-visible text
// the card does not carry verbatim (a web_fetch card shows only the URL and
// status, so its fetched body lives only here; a search card's answer and
// sources are structured, so the flattened form repeats them as the raw text
// the model saw).
if (web !== null) {
const settled = 'kind' in material.block ? material.block : null
const body = settled === null ? '' : resultText(settled)
return (
<>
<WebBlock {...web} className={css.web} />
{body !== '' && <pre className={css.code}>{body}</pre>}
</>
)
}
// A settled call always carries the result node the flattened form needs;
// the running shape has no result to flatten.
if (!('kind' in material.block)) return <div className={css.empty}>{t('details.running')}</div>
@@ -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;
}
@@ -0,0 +1,92 @@
// Web toolview registrant: third-party posture over the keyed toolview hole
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Registered under BOTH web_search and web_fetch, since both declare the one
// `web` render intent and render through the one WebBlock family; the row
// discriminates on the toolName only to pick its icon and title.
//
// A web tool declares the `web` render intent at result time, so this row
// renders the completed retrieval through WebBlock resident below its summary,
// the same posture BashRow uses for the terminal card: no expand control on the
// row itself, not a details-panel target, and the block's own expander keeps a
// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is
// passed as maxSources — the chat flow's tighter cap over the block's default
// of 16). Until the call settles there is no web card (the tools keep a generic
// pending view), so a running row is the summary line alone.
import type { Context } from 'cordis'
import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './web-row.module.css'
/** web_fetch reads one URL; web_search queries. Titles are figma literals. */
const WEB_TITLES: Record<string, string> = {
web_search: 'Search',
web_fetch: 'Fetch',
}
/** Leading icon per tool, yielding to the state semantic while failed/stopped. */
function leadingFor(toolName: string, state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
// Running keeps the icon — the row sweep carries the in-flight signal.
default: return toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with
* the completed retrieval's web card resident below it. The summary row is not
* a details-panel control (tool rows stopped being one), so the card's own
* links and expander are the row's only interactions.
*/
export function WebRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const web = webCardModel(block)
const status = stateStatus(model.state)
return (
<div className={css.card}>
<div className={css.root} data-variant="web" data-tool={toolName} data-state={model.state}>
<span className={css.leading}>{leadingFor(toolName, model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{WEB_TITLES[toolName] ?? model.title}</span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{model.summary}</span>
</div>
{web !== null && (
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
)}
</div>
)
}
/**
* The web rows as a plain registrant plugin, riding the same load-order seam as
* the bash sample: `inject: ['conversation']` guarantees the chat entry (and
* with it the 'conversation.chat.toolview' declaration) is on the ledger. One
* WebRow component registers under both web tool names.
*/
export const webToolview = {
name: 'web-toolview',
inject: ['slots', 'conversation'],
/**
* Register the web row under both web tool names' keyed toolview holes.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow)
},
}
@@ -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()
@@ -0,0 +1,270 @@
// @vitest-environment jsdom
// The web render intent on the web side: the pure webCardModel derivation over
// resultView, and the conversation render sites that consume it — the keyed
// WebRow (registered under both web_search and web_fetch), the GenericToolCard
// render-site fallback, and the details panel's Output section. Mirrors
// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat
// row's resident card, the panel arm, and the keyed registration.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Locale seat for the card render sites (GenericToolCard, DetailsPanel), as the sibling suites build it. */
const t = makeTranslate(zh, commonZh)
const SEARCH_ARGS = '{"query":"deepseek harness"}'
const FETCH_ARGS = '{"url":"https://example.com/page"}'
/** A web_search result view; overrides tune the sources / answer / truncation. */
const resultSearch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'search' }>>): ToolResultView => ({
card: 'web', kind: 'search', truncated: false,
answer: 'A short answer.',
sources: [
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
{ url: 'https://plain.example.org/b' },
],
...over,
})
/** A web_fetch result view. */
const resultFetch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>>): ToolResultView => ({
card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over,
})
const runningSearch = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over,
})
const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'web_search', argsRaw: SEARCH_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'search text' }], isError: false,
callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over,
})
const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2',
call: { name: 'web_fetch', argsRaw: FETCH_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'fetch body' }], isError: false,
callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over,
})
describe('webCardModel', () => {
it('derives a search card from the result view, projecting every source field', () => {
expect(webCardModel(settledSearch())).toEqual({
kind: 'search',
answer: 'A short answer.',
truncated: false,
sources: [
{ url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' },
{ url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined },
],
})
})
it('carries the search truncation flag and an absent answer', () => {
const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } }))
expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] })
})
it('derives a fetch card from the result view', () => {
expect(webCardModel(settledFetch())).toEqual({
kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false,
})
expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) })))
.toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true })
})
it('returns null for a running call, since the web card is result-only', () => {
expect(webCardModel(runningSearch())).toBeNull()
// Even a running call that somehow carried a web call view stays generic:
// the derivation reads resultView only.
expect(webCardModel(runningSearch({ callView: null }))).toBeNull()
})
it('returns null for a settled call whose result view is not a web card', () => {
expect(webCardModel(settledSearch({ resultView: null }))).toBeNull()
expect(webCardModel(settledSearch({ resultView: { card: 'generic' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView
expect(webCardModel(settledSearch({ resultView: future }))).toBeNull()
// A web card whose kind this UI version does not know (a newer host's
// value) also takes the generic path, not a malformed fetch.
const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView
expect(webCardModel(settledSearch({ resultView: futureKind }))).toBeNull()
})
})
describe('chat row web body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({
callId: block.callId, toolName, block, openFile: vi.fn(),
})
// WebRow reads only toolName/block off the full runtime share; the standard
// kit is unused, so the cast supplies the owner slice alone (as BashRow's
// tests do for the terminal card).
const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps =>
ownerProps(block, toolName) as unknown as ToolRowProps
it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => {
expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16)
const view = render(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
// The summary row plus the resident card, without any expand gesture on the row itself.
expect(view.getByText('Search')).toBeTruthy()
expect(view.getByText('Titled')).toBeTruthy()
expect(view.getByText('excerpt')).toBeTruthy()
// hostname fallback for the source with no title
expect(view.getByText('plain.example.org')).toBeTruthy()
})
it('the WebRow renders the fetch card resident, titled Fetch', () => {
const view = render(<WebRow {...rowProps(settledFetch(), 'web_fetch')} />)
expect(view.getByText('Fetch')).toBeTruthy()
// The url shows in the summary row and as the card's link; scope to the card.
const card = view.container.querySelector('[data-web="fetch"]')
expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
expect(view.getByText('HTTP 200')).toBeTruthy()
})
it('a running web call is the summary row alone (no card until it settles)', () => {
const view = render(<WebRow {...rowProps(runningSearch(), 'web_search')} />)
expect(view.getByText('Search')).toBeTruthy()
expect(view.queryByText('Titled')).toBeNull()
expect(view.container.querySelector('[data-web]')).toBeNull()
})
it('a failed web call keeps the summary row without the card', () => {
const view = render(<WebRow {...rowProps(settledSearch({
isError: true, resultView: { card: 'generic' },
}), 'web_search')} />)
expect(view.getByText('Search')).toBeTruthy()
expect(view.container.querySelector('[data-web]')).toBeNull()
// The row reflects the error state so the summary line still reads as failed.
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
})
it('the GenericToolCard fallback also renders a resident web card for a web-declaring tool', () => {
// A web-declaring tool without its own keyed row lands on the fallback; its
// card is resident there too.
const view = render(<GenericToolCard {...ownerProps(settledSearch({
call: { name: 'fx-web', argsRaw: SEARCH_ARGS },
}), 'fx-web')} t={t} />)
expect(view.getByText('Titled')).toBeTruthy()
expect(view.container.querySelector('[data-web="search"]')).not.toBeNull()
})
it('the GenericToolCard fallback keeps the plain row for a non-web call', () => {
const view = render(<GenericToolCard {...ownerProps(settledSearch({
call: { name: 'echo', argsRaw: '{}' }, callView: null, resultView: null,
}), 'echo')} t={t} />)
expect(view.container.querySelector('[data-web]')).toBeNull()
})
})
describe('DetailsPanel web Output section', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
t={t}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
}
}
it('renders the search card at full source allowance', () => {
const view = mount(snapshot({ nodes: [settledSearch()] }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
expect(view.getByText('Titled')).toBeTruthy()
expect(view.getByText('excerpt')).toBeTruthy()
// The Input JSON section survives beside it.
expect(view.getByText(/"query"/)).toBeTruthy()
})
it('renders the fetch card and keeps the fetched body below it', () => {
const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' })
const card = view.container.querySelector('[data-web="fetch"]')
expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
expect(view.getByText('HTTP 200')).toBeTruthy()
// The card is a summary (URL + status only); the panel is the single-call
// reading surface, so the fetched body still renders below the card.
const output = view.getByText('输出').closest('section')
expect(output?.querySelector('pre')?.textContent).toContain('fetch body')
})
it('a non-web result keeps the flattened pre form', () => {
const view = mount(snapshot({
nodes: [settledSearch({ callView: null, resultView: null })],
}), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
expect(view.container.querySelector('[data-web]')).toBeNull()
const output = view.getByText('输出').closest('section')
expect(output?.querySelector('pre')?.textContent).toContain('search text')
})
})
describe('web toolview registration', () => {
it('registers one WebRow under both web_search and web_fetch', () => {
const registered: { key: string; component: unknown }[] = []
const ctx = {
slots: {
register: (options: { name: string; key: string }, component: unknown) => {
registered.push({ key: options.key, component })
return () => {}
},
},
} as unknown as import('cordis').Context
webToolview.apply(ctx)
expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch'])
// One component under both keys, not two thin rows.
expect(registered[0]?.component).toBe(WebRow)
expect(registered[1]?.component).toBe(WebRow)
// The load-order seam the render site depends on.
expect(webToolview.inject).toEqual(['slots', 'conversation'])
})
})
@@ -2,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
+6 -2
View File
@@ -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 `<li value>`, and the expand control is a marker-less `<li>` so the `<ol>` 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 `<ol>` (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.
+6 -2
View File
@@ -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 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `<li value>` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `<li>`,使 `<ol>` 保持为合法 HTML。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `<ol>`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。
@@ -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;
}
@@ -0,0 +1,242 @@
// WebBlock: the surface for a completed web retrieval. One component draws both
// kinds of the `web` render intent, discriminated by `kind`: a `search` shows an
// optional provider answer above a citation list of sources (each a safe
// external link labelled by its title, or its hostname when the provider gave
// none, with the snippet and publication date below it), and a `fetch` shows a
// compact retrieval summary (the linked final URL and its HTTP status). Both
// mark a capped retrieval. Every link is a same-origin-safe external anchor:
// only http(s) URLs become anchors (target/rel set) — the http(s) subset of the
// allowlist MarkdownText applies to untrusted assistant-authored links (it also
// permits mailto, excluded here); an unparseable or non-http URL renders as
// plain text. Geometry, radius, and fonts mirror CodeBlock/TerminalBlock so a
// web card reads as one family with them; a long source list caps at maxSources
// with a head/tail collapse using the same arithmetic as TerminalBlock's output
// cap.
import { useCallback, useState } from 'react'
import clsx from 'clsx'
import { MarkdownText } from './markdown/MarkdownText.tsx'
import css from './WebBlock.module.css'
/**
* Sources shown before the height cap collapses the middle of a citation list.
* Matches TerminalBlock's default output budget so both cards cut a long body
* at the same place; the chat row narrows it through the maxSources prop.
*/
export const DEFAULT_WEB_MAX_SOURCES = 16
/**
* One citeable source drawn in a search card: the projection of the contract's
* `WebSource`, with the optional fields kept optional so a provider that
* returned only a URL still renders (its hostname becomes the label).
*/
export interface WebSourceView {
/** The source URL; becomes a safe external link when it is http(s). */
url: string
/** The source title; when absent the URL's hostname labels the link. */
title?: string | undefined
/** A short excerpt or summary shown under the link. */
snippet?: string | undefined
/** Publication/crawl timestamp, a provider-supplied string shown under the link. */
publishedAt?: string | undefined
}
/** A `web_search` card: an optional answer over a capped citation list. */
export interface WebSearchBlockProps {
kind: 'search'
/** The provider-generated answer, rendered as markdown above the sources. */
answer?: string | undefined
/** The cited sources, in provider order. */
sources: WebSourceView[]
/** True when the tool cut the source list to its result cap. */
truncated: boolean
/** Sources shown before the middle collapses (default {@link DEFAULT_WEB_MAX_SOURCES}). */
maxSources?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/** A `web_fetch` card: the retrieval summary for one fetched URL. */
export interface WebFetchBlockProps {
kind: 'fetch'
/** The final URL after allowed redirects; becomes a safe external link when http(s). */
url: string
/** HTTP status code of the fetched response. */
statusCode: number
/** True when the provider or the output cap cut the fetched content. */
truncated: boolean
/**
* Accepted and ignored, so both card kinds take one uniform prop set (a fetch
* card has no source list to cap) — the same way TerminalBlock accepts one
* `maxLines` across its arms. Lets a render site spread `maxSources` onto
* either kind without a per-kind conditional.
*/
maxSources?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/** A completed web retrieval card, discriminated by `kind`. */
export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps
/**
* The URL to link to, or undefined when the URL must render as plain text. Only
* http(s) becomes a navigable external anchor, so a `javascript:`/`data:`/`file:`
* URL or an unparseable string never reaches the DOM as an href. This is the
* http(s) subset of the allowlist MarkdownText applies to untrusted links —
* MarkdownText also permits `mailto:`, deliberately excluded here since a
* retrieval URL is never a mail address.
* @param url - the source or fetch URL, from tool result content.
* @returns the href to use, or undefined for plain text.
*/
function safeHref(url: string): string | undefined {
try {
const { protocol } = new URL(url)
return protocol === 'http:' || protocol === 'https:' ? url : undefined
} catch {
return undefined
}
}
/**
* The link's visible label: the title when the provider gave one, otherwise the
* URL's hostname, falling back to the raw URL when it does not parse OR parses
* to an empty hostname (a `file:`/`data:`/`javascript:` URL), so a label is
* never blank.
* @param url - the source URL.
* @param title - the provider title, if any.
* @returns the label text.
*/
function linkLabel(url: string, title: string | undefined): string {
if (title !== undefined && title !== '') return title
try {
const { hostname } = new URL(url)
return hostname === '' ? url : hostname
} catch {
return url
}
}
/**
* A single URL rendered as a safe external anchor, or as plain text when the
* URL is not an http(s) link.
* @param props.url - the URL to render.
* @param props.label - the visible label.
* @param props.className - class for the anchor or the plain span.
* @returns the anchor or span element.
*/
function SafeLink({ url, label, className }: { url: string; label: string; className?: string | undefined }) {
const href = safeHref(url)
if (href === undefined) return <span className={className}>{label}</span>
return (
<a className={className} href={href} target="_blank" rel="noopener noreferrer">
{label}
</a>
)
}
/**
* One source row in a search card: the safe link plus its snippet and date. The
* `<li value>` pins the source's original 1-based position, so a collapsed list
* whose tail is drawn after the head still numbers each source by its real
* citation index rather than by its position in the visible subset.
* @param props.source - the source to render.
* @param props.ordinal - the source's 1-based position in the full list.
* @returns the source list item.
*/
function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: number }) {
return (
<li className={css.source} value={ordinal}>
<SafeLink url={source.url} label={linkLabel(source.url, source.title)} className={css.sourceLink} />
{source.snippet !== undefined && source.snippet !== '' && (
<div className={css.snippet}>{source.snippet}</div>
)}
{source.publishedAt !== undefined && source.publishedAt !== '' && (
<div className={css.published}>{source.publishedAt}</div>
)}
</li>
)
}
/**
* The search card body: the answer over the capped source list.
* @param props - see {@link WebSearchBlockProps}.
* @returns the search card element.
*/
function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_MAX_SOURCES, className }: WebSearchBlockProps) {
const [expanded, setExpanded] = useState(false)
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const hidden = sources.length - maxSources
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock's output cap, so a long body's head
// and tail slices agree between the two cards.
const headCount = Math.ceil(maxSources / 2)
const tailCount = maxSources - headCount
const head = capped ? sources.slice(0, headCount) : sources
const tail = capped ? sources.slice(sources.length - tailCount) : []
// A provider may legitimately return no answer and no sources; the chat WebRow
// does not show the raw result content, so without this the user would see an
// empty card. Mirror the backend's `No results found.` render text.
const empty = (answer === undefined || answer === '') && sources.length === 0
return (
<div className={clsx(css.block, className)} data-web="search">
{answer !== undefined && answer !== '' && (
<div className={css.answer}><MarkdownText text={answer} /></div>
)}
{empty ? (
<div className={css.empty}></div>
) : (
<ol className={css.sources}>
{head.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
{hidden > 0 && (
<li className={css.expandItem}>
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起来源' : `展开其余 ${hidden} 条来源`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden} 条来源`}
</button>
</li>
)}
{tail.map((source, index) => (
<SourceItem
key={sources.length - tailCount + index}
source={source}
ordinal={sources.length - tailCount + index + 1}
/>
))}
</ol>
)}
{truncated && <div className={css.truncated}></div>}
</div>
)
}
/**
* The fetch card body: the linked URL and its HTTP status.
* @param props - see {@link WebFetchBlockProps}.
* @returns the fetch card element.
*/
function WebFetchBlock({ url, statusCode, truncated, className }: WebFetchBlockProps) {
return (
<div className={clsx(css.block, css.fetch, className)} data-web="fetch">
<SafeLink url={url} label={url} className={css.fetchUrl} />
<div className={css.fetchMeta}>
<span className={css.status}>HTTP {statusCode}</span>
{truncated && <span className={css.truncated}></span>}
</div>
</div>
)
}
/**
* Render a completed web retrieval as a structured card.
* @param props - see {@link WebBlockProps}; `kind` selects the search or fetch body.
* @returns the web card element.
*/
export function WebBlock(props: WebBlockProps) {
return props.kind === 'search' ? <WebSearchBlock {...props} /> : <WebFetchBlock {...props} />
}
@@ -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'
@@ -0,0 +1,211 @@
// @vitest-environment jsdom
// WebBlock: both kinds of the web card. The search card's answer, its citation
// list with the title-or-hostname label fallback and optional snippet/date, the
// source-list height cap and its expand control, and the truncated indicator;
// the fetch card's linked URL, status, and truncation. Safe-link attributes on
// both kinds: an http(s) URL becomes an external anchor (target/rel), any other
// URL renders as plain text with no href.
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts'
import type { WebSourceView } from '../src/index.ts'
afterEach(cleanup)
/** `count` sources with sequential hostnames, so the cap slices read distinctly. */
function sources(count: number): WebSourceView[] {
return Array.from({ length: count }, (_value, index) => ({
url: `https://site-${index}.example.com/page`,
title: `Source ${index}`,
}))
}
describe('WebBlock search card', () => {
it('renders the answer above the citation list', () => {
const view = render(<WebBlock kind="search" answer="**Answer** text" sources={sources(2)} truncated={false} />)
expect(view.getByText('Answer')).toBeTruthy()
expect(view.getByText('Source 0')).toBeTruthy()
expect(view.getByText('Source 1')).toBeTruthy()
})
it('omits the answer block when there is no answer', () => {
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
expect(view.container.querySelector('[class^="_answer_"]')).toBeNull()
const empty = render(<WebBlock kind="search" answer="" sources={sources(1)} truncated={false} />)
expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull()
})
it('shows the empty-state note when a search returns no answer and no sources', () => {
const view = render(<WebBlock kind="search" sources={[]} truncated={false} />)
expect(view.getByText('未找到结果')).toBeTruthy()
// The empty note replaces the source list, not an empty <ol>.
expect(view.container.querySelector('ol')).toBeNull()
})
it('shows the source list, not the empty note, when a source is present', () => {
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
expect(view.container.querySelector('ol')).toBeTruthy()
expect(view.queryByText('未找到结果')).toBeNull()
})
it('shows the source list when an empty source list still carries an answer', () => {
const view = render(<WebBlock kind="search" answer="Just an answer" sources={[]} truncated={false} />)
expect(view.getByText('Just an answer')).toBeTruthy()
expect(view.queryByText('未找到结果')).toBeNull()
})
it('labels a source by its title, and by hostname when the title is absent', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://example.com/a', title: 'Titled' },
{ url: 'https://plain.example.org/b' },
{ url: 'https://empty.example.net/c', title: '' },
]} />)
expect(view.getByText('Titled')).toBeTruthy()
// No title / empty title: the hostname labels the link.
expect(view.getByText('plain.example.org')).toBeTruthy()
expect(view.getByText('empty.example.net')).toBeTruthy()
})
it('labels a source by the raw url when it parses to an empty hostname', () => {
// file:/data:/javascript: URLs parse but have no hostname; the label must
// fall back to the raw URL so it is never blank (and the link stays plain
// text since the protocol is not http(s)).
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'file:///etc/passwd' },
]} />)
expect(view.getByText('file:///etc/passwd')).toBeTruthy()
})
it('renders a source as a safe external anchor for an http(s) url', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://example.com/a', title: 'Titled' },
]} />)
const anchor = view.getByText('Titled') as HTMLAnchorElement
expect(anchor.tagName).toBe('A')
expect(anchor.getAttribute('href')).toBe('https://example.com/a')
expect(anchor.getAttribute('target')).toBe('_blank')
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
})
it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'javascript:alert(1)', title: 'Dangerous' },
{ url: 'not a url' },
]} />)
const unsafe = view.getByText('Dangerous')
expect(unsafe.tagName).toBe('SPAN')
expect(unsafe.getAttribute('href')).toBeNull()
// An unparseable url is not a link and cannot yield a hostname, so its raw
// text is the label.
const raw = view.getByText('not a url')
expect(raw.tagName).toBe('SPAN')
})
it('shows a source snippet and publication date when present, and omits them when absent or empty', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://a.example.com', title: 'A', snippet: 'excerpt', publishedAt: '2026-07-01' },
{ url: 'https://b.example.com', title: 'B', snippet: '', publishedAt: '' },
{ url: 'https://c.example.com', title: 'C' },
]} />)
expect(view.getByText('excerpt')).toBeTruthy()
expect(view.getByText('2026-07-01')).toBeTruthy()
// The empty-string and absent arms both draw nothing beyond the link.
expect(view.container.querySelectorAll('[class^="_snippet_"]')).toHaveLength(1)
expect(view.container.querySelectorAll('[class^="_published_"]')).toHaveLength(1)
})
it('shows the truncated indicator only when the list was capped by the tool', () => {
const on = render(<WebBlock kind="search" sources={sources(1)} truncated />)
expect(on.getByText('来源列表已截断')).toBeTruthy()
cleanup()
const off = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
expect(off.queryByText('来源列表已截断')).toBeNull()
})
it('renders every source and no expand control under the cap', () => {
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} maxSources={4} />)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
})
it('slices head and tail over the cap and expands on click', () => {
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
// maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent))
.toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9'])
const toggle = view.getByRole('button', { name: '展开其余 6 条来源' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.textContent).toBe('… 其余 6 条来源')
fireEvent.click(toggle)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10)
const collapse = view.getByRole('button', { name: '收起来源' })
expect(collapse.getAttribute('aria-expanded')).toBe('true')
expect(collapse.textContent).toBe('收起')
fireEvent.click(collapse)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
})
it('numbers a collapsed tail by each source original position, not its visible slot', () => {
// maxSources 4 over 10 sources: the tail is sources 8 and 9, which must read
// as citations 9 and 10 (via <li value>), not renumbered 3 and 4.
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '9', '10'])
})
it('keeps the expander out of the ordered-list numbering', () => {
// The expander is a marker-less <li>, so it is valid inside <ol> and does not
// consume a citation number between the head and tail sources.
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
const ol = view.container.querySelector('ol')!
// Every direct child is an <li> (no bare <button> child — invalid HTML).
expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
})
it('renders the head slice alone when the cap leaves no tail', () => {
const view = render(<WebBlock kind="search" sources={sources(5)} truncated={false} maxSources={1} />)
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0'])
expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy()
})
it('caps at the documented default when maxSources is absent', () => {
const view = render(<WebBlock kind="search" sources={sources(DEFAULT_WEB_MAX_SOURCES + 1)} truncated={false} />)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES)
expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy()
})
})
describe('WebBlock fetch card', () => {
it('renders the fetched url as a safe external anchor and its HTTP status', () => {
const view = render(<WebBlock kind="fetch" url="https://example.com/page" statusCode={200} truncated={false} />)
const anchor = view.getByText('https://example.com/page') as HTMLAnchorElement
expect(anchor.tagName).toBe('A')
expect(anchor.getAttribute('href')).toBe('https://example.com/page')
expect(anchor.getAttribute('target')).toBe('_blank')
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
expect(view.getByText('HTTP 200')).toBeTruthy()
})
it('renders a non-http fetch url as plain text with no href', () => {
const view = render(<WebBlock kind="fetch" url="file:///etc/passwd" statusCode={200} truncated={false} />)
const label = view.getByText('file:///etc/passwd')
expect(label.tagName).toBe('SPAN')
expect(label.getAttribute('href')).toBeNull()
})
it('shows the truncated indicator only when the content was cut', () => {
const on = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated />)
expect(on.getByText('内容已截断')).toBeTruthy()
cleanup()
const off = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated={false} />)
expect(off.queryByText('内容已截断')).toBeNull()
})
it('carries a non-200 status verbatim', () => {
const view = render(<WebBlock kind="fetch" url="https://example.com/missing" statusCode={404} truncated={false} />)
expect(view.getByText('HTTP 404')).toBeTruthy()
})
})
@@ -364,10 +364,6 @@ export class CredentialsLocal extends Credentials {
// After the commit: a broken observer must never make the durable
// write look failed (an INVARIANT failure still rethrows).
this.notifyUpdated(ref)
}, {
onStaleBreak: (lockPath) => {
this.ctx.logger.warn('credentials-local: breaking a stale writer lock at %s', lockPath)
},
})
})
}
@@ -4,7 +4,7 @@
// editor's multi-line and CRLF discipline.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
@@ -68,17 +68,6 @@ describe('read-modify-write', () => {
expect(await third.credentials.resolve(BETA)).toEqual({ value: '3', source: 'file' })
})
it('breaks a stale writer lock with a warning and writes through', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
await writeFile(`${path}.lock`, 'crashed-holder\n')
const past = (Date.now() - 60_000) / 1000
await utimes(`${path}.lock`, past, past)
await ctx.credentials.set(ALPHA, 'nine')
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=nine`)
})
it('creates the credentials directory owner-only', async () => {
const dir = await tempDir()
const home = join(dir, 'home')
@@ -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/settings/settings-local/README.md
README.md: 2c0817afd2f2fd35fda2d22cd7f7ef3772fe2257
README.zh.md: 547abb035368f07d4478a5c3a1793cdaa6743c68
README.md: 344300c33879918e836b6e208b172343cc472faa
README.zh.md: 7e4913c0883c48c23de3408a3b0fe0455160984f
+1 -1
View File
@@ -19,7 +19,7 @@ Defaulting is one explicit `resolveSpec(config)` step; an unsupported extension
- **Boot fails loud, reload keeps last-good.** An existing-but-invalid document fails plugin load; once live, an unreadable or unparsable edit warns and keeps the last good sections. A missing document resolves every namespace from defaults and `base`; deleting it publishes the same empty state.
- **Every write is a read-modify-write.** A persist first re-reads the document and publishes any difference into the seam — an external edit still inside the watcher debounce window, a change the watcher missed, or another process's write — then renders against that fresh text, so a write can never resurrect a stale document or drop an unobserved sibling section. If the on-disk document turned invalid, the write rejects loud instead of overwriting the user's manual edit.
- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `<file>.lock` sibling with exponential backoff, a 2 s acquisition deadline (the write rejects), and stale-lock takeover after 5 s (a crashed holder, broken with a warning). Readers never take the lock: the rename commit is atomic, so reloads are always consistent.
- **Writes hold a cross-process writer lock.** The read-render-rename cycle runs under a `wx`-created `<file>.lock` sibling 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 take the lock: the rename commit is atomic, so reloads are always consistent.
- **Write-back is atomic, owner-only, and symlink-proof.** The render exclusive-creates a random-suffix temp sibling with mode `0600` (`wx` refuses to follow a planted symlink) and renames over the target, cleaning the temp up on failure.
- **YAML edits are leaf-level diffs.** A write sets only the values that changed and deletes only the keys that were removed, so comments, anchors, and formatting survive on every untouched node and on the key of every changed pair; a changed array (or other non-map value) replaces wholesale, taking comments inside it along. JSON re-serializes without comments.
- **Reloads and writes share one operation chain.** Watcher refreshes and persists from every namespace queue run one at a time in queue order; each render sees the text the previous operation committed.
@@ -19,7 +19,7 @@
- **启动报错响亮,重载保留最后可用值。** 存在但非法的文档使插件加载失败;运行中不可读或不可解析的编辑只告警并保留最后可用分节。文档缺失时所有 namespace 按默认值与 `base` 解析;删除文档发布同样的空状态。
- **每次写入都是一次读-改-写。** persist 先重读文档并把任何差异发布进 seam——无论是仍在 watcher 防抖窗口内的外部编辑、watcher 漏掉的变更,还是另一个进程的写入——再基于这份新鲜文本渲染,因此写入绝不会复活陈旧文档,也不会丢掉未观察到的同级分节。若磁盘上的文档已变为非法,写入响亮拒绝,而不是覆盖用户的手工编辑。
- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `<file>.lock` 同级文件下运行,带指数退避2 s 的获取期限(到期则写入拒绝)与 5 s 后的陈旧锁接管(持有者已崩溃,破锁并告警)。读取方从不取锁:rename 提交是原子的,重载因此始终一致。
- **写入持有跨进程写锁。** 读-渲染-rename 流程在 `wx` 创建的 `<file>.lock` 同级文件下运行,带指数退避2 s 的获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读取方从不取锁:rename 提交是原子的,重载因此始终一致。
- **写回原子、仅属主可读、抗符号链接。** 渲染以 `0600` 权限独占创建随机后缀临时同级文件(`wx` 拒绝跟随预埋符号链接)后 rename 覆盖目标,失败时清理临时文件。
- **YAML 编辑是叶子级 diff。** 写入只设置发生变化的值、只删除被移除的键,因此注释、锚点与排版在每个未触碰的节点上以及每个被改键值对的键上都得以保留;被改的数组(或其他非 map 值)整体替换,其中的注释随之一同被换掉。JSON 重新序列化,无注释。
- **重载与写入共享一条操作链。** watcher 刷新与来自各 namespace 队列的 persist 按队列顺序逐个执行;每次渲染都基于上一次操作提交后的文本。
@@ -198,10 +198,6 @@ export class SettingsLocal extends Settings {
// 0600: a document that may hold personal values is never world-readable.
await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 })
this.text = output
}, {
onStaleBreak: (lockPath) => {
this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath)
},
})
}
@@ -70,25 +70,20 @@ describe('writer lock', () => {
expect(await readFile(path, 'utf8')).toContain('value: 7')
})
it('breaks a stale writer lock with a warning and writes through', async () => {
it('does not steal an old writer lock', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'alpha:\n value: 4\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
await writeFile(`${path}.lock`, 'crashed-holder\n')
const lockPath = `${path}.lock`
await writeFile(lockPath, 'slow-holder\n')
const past = (Date.now() - 60_000) / 1000
await utimes(`${path}.lock`, past, past)
await scope.update({ value: 9 })
expect(await readFile(path, 'utf8')).toContain('value: 9')
})
await utimes(lockPath, past, past)
it('times out on a lock a live holder never releases', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
await writeFile(`${path}.lock`, 'busy-holder\n')
await expect(scope.update({ value: 1 })).rejects.toThrow(/timed out waiting for the writer lock/)
await expect(scope.update({ value: 9 })).rejects.toThrow(/timed out waiting for the writer lock/)
expect(await readFile(path, 'utf8')).toContain('value: 4')
expect(await readFile(lockPath, 'utf8')).toBe('slow-holder\n')
}, 10_000)
it('surfaces a non-contention lock failure as the write error', async () => {
@@ -1,8 +1,5 @@
// Writer-lock races that cannot be timed from outside: a contender whose lock
// vanishes between the failed exclusive create and the stat, a stat failing
// for a reason other than absence, and a temp-file write failing mid-cycle.
// The fs/promises seam is partially mocked to inject exactly one failure at a
// chosen path suffix; everything else passes through to the real filesystem.
// A temp-file write failure cannot be timed from outside. The fs/promises seam
// injects it once so the test can prove that the writer lock still releases.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
@@ -13,28 +10,20 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal } from '../src/index.ts'
const state = vi.hoisted(() => ({
/** One-shot failure injections keyed by operation, matched on a path suffix. */
failures: [] as Array<{ op: 'writeFile' | 'stat'; suffix: string; code: string }>,
failTempWrite: false,
}))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
const inject = (op: 'writeFile' | 'stat', path: unknown): void => {
const index = state.failures.findIndex(f => f.op === op && String(path).endsWith(f.suffix))
if (index === -1) return
const [failure] = state.failures.splice(index, 1)
throw Object.assign(new Error(`${failure!.code}: injected ${op} failure`), { code: failure!.code })
}
return {
...actual,
writeFile: (async (path: unknown, ...rest: never[]) => {
inject('writeFile', path)
if (state.failTempWrite && String(path).endsWith('.tmp')) {
state.failTempWrite = false
throw Object.assign(new Error('ENOSPC: injected writeFile failure'), { code: 'ENOSPC' })
}
return (actual.writeFile as (path: unknown, ...args: never[]) => Promise<void>)(path, ...rest)
}) as typeof actual.writeFile,
stat: (async (path: unknown, ...rest: never[]) => {
inject('stat', path)
return (actual.stat as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
}) as typeof actual.stat,
}
})
@@ -43,7 +32,7 @@ const AlphaSchema: z<{ value: number }> = z.object({ value: z.number().default(0
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
state.failures.length = 0
state.failTempWrite = false
while (cleanups.length > 0) await cleanups.pop()!()
})
@@ -61,37 +50,14 @@ async function boot(config: ConstructorParameters<typeof SettingsLocal>[1]): Pro
return ctx
}
describe('writer-lock races', () => {
it('retries immediately when the contending lock vanished before the stat', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
// The exclusive create loses to a holder that releases before the stat:
// no lock file actually exists, so the stat sees honest absence and the
// very next attempt takes the lock.
state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' })
await scope.update({ value: 3 })
expect(await readFile(path, 'utf8')).toContain('value: 3')
})
it('propagates a stat failure that does not mean absence', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
state.failures.push({ op: 'writeFile', suffix: '.lock', code: 'EEXIST' })
state.failures.push({ op: 'stat', suffix: '.lock', code: 'EACCES' })
await expect(scope.update({ value: 3 })).rejects.toThrow(/EACCES/)
})
describe('writer-lock failure cleanup', () => {
it('cleans up the temp file and releases the lock when the write fails mid-cycle', async () => {
const dir = await tempDir()
const path = join(dir, 'settings.yaml')
await writeFile(path, 'alpha:\n value: 1\n')
const ctx = await boot({ path, watch: false })
const scope = ctx.settings.register(settingsNamespace('alpha'), AlphaSchema)
state.failures.push({ op: 'writeFile', suffix: '.tmp', code: 'ENOSPC' })
state.failTempWrite = true
await expect(scope.update({ value: 9 })).rejects.toThrow(/ENOSPC/)
// The document is untouched and the writer lock was released on the way out.
expect(await readFile(path, 'utf8')).toContain('value: 1')
+2 -2
View File
@@ -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/util/atomic-write/README.md
README.md: be9f896eb24e28aedc2c04858da8b8da9da548dc
README.zh.md: 19a067dc84f12d334e5c31dda58e7cf78dac51f9
README.md: 2ff4abb6ac10d8b592ccd2056b4f1f92cc8518b0
README.zh.md: bd5d3f1f2583ff1b97a80ce2c1c6ca7989e13d15
+2 -2
View File
@@ -28,7 +28,7 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => {
- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic.
- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content.
`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `<filename>.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A lock older than the stale age is treated as a crashed holder and broken — see [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) for what that costs.
`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `<filename>.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A contender never removes the existing lock: age cannot distinguish a crashed owner from a paused live writer.
## Model Experience
@@ -42,4 +42,4 @@ None; nothing here enters a request prefix.
- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy.
- **String content only** — no `Buffer` or stream form until a consumer needs one.
- **The lock takes over by age, not by ownership** (`TODO(settings-lock-ownership)`) — a holder slower than the stale age has its lock broken by a waiter, and release unlinks the path unconditionally, so a slow writer can remove a successor's lock. Two writers can then overlap and one cycle's result be lost. The stale age is set well above any write this repo performs, so the exposure is a paused or swapped-out process; ownership-safe acquisition and release is the fix.
- **Orphaned locks require operator recovery** — a process that exits while holding the lock can leave the sibling behind. Later writers time out without deleting it; an operator removes it only after verifying that no writer still owns it. File age alone is not safe evidence of abandonment.
+2 -2
View File
@@ -28,7 +28,7 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => {
- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。
- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。
`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `<filename>.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。超过陈旧时限的锁被视为持有者已崩溃并被打破——其代价见[Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `<filename>.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。竞争者绝不移除现有锁:锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方
## Model Experience
@@ -42,4 +42,4 @@ await withFileLock('/home/u/.dsh/settings.yaml', async () => {
- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。
- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。
- **锁按时长而非归属接管**`TODO(settings-lock-ownership)`)——持有者若慢于陈旧时限,其锁会被等待方打破,而释放又无条件删除该路径,因此慢写入方可能删掉后继者的锁。两个写入方随之重叠,一轮循环的结果可能丢失。陈旧时限远高于本仓库的任何一次写入,因此暴露面是被暂停或被换出的进程;修法是按归属安全地获取与释放
- **遗留锁需要操作者恢复**——进程持锁退出时可能留下同级锁文件。后续写入方超时也不会删除它;操作者只有在确认没有写入方仍拥有该锁后才会移除。文件存续时间本身不能安全证明它已无人持有
+8 -47
View File
@@ -11,7 +11,7 @@
*/
import { randomBytes } from 'node:crypto'
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
/**
@@ -68,59 +68,31 @@ function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
/** Whether a filesystem error means absence. */
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/**
* Writer-lock protocol constants. These are robustness invariants of the
* cross-process write protocol, not deployment tunables: a holder rewrites one
* small file in milliseconds, so contention resolves well inside the retry
* deadline, and a lock older than the stale age can only belong to a crashed
* holder.
* cross-process write protocol, not deployment tunables: contention normally
* resolves within the retry deadline, while expiry fails the contender without
* guessing whether the existing lock still has an owner.
*/
const LOCK_RETRY_INITIAL_MS = 20
const LOCK_RETRY_MAX_MS = 200
const LOCK_TIMEOUT_MS = 2_000
const LOCK_STALE_MS = 5_000
/** Options for {@link withFileLock}. */
export interface WithFileLockOptions {
/**
* Called once each time a stale (crashed-holder) lock is broken, so the
* caller can log the takeover in its own voice.
*/
onStaleBreak?: (lockPath: string) => void
}
/** Age of the lock file, or `undefined` when it vanished after a failed create. */
async function lockAgeMs(lockPath: string): Promise<number | undefined> {
try {
return Date.now() - (await stat(lockPath)).mtimeMs
} catch (error) {
if (!isENOENT(error)) throw error
return undefined
}
}
/**
* Hold the cross-process writer lock for `filename` around one operation. The
* lock is a `wx`-created sibling (`<filename>.lock`); paired with the
* rename-based commit of {@link writeFileAtomic}, readers stay lock-free and
* only writers contend. Contention backs off exponentially; a lock older than
* the stale age is a crashed holder and is broken (see
* {@link WithFileLockOptions.onStaleBreak}); a live holder past the deadline
* fails the operation with a timed-out error. The parent directory must exist.
* only writers contend. Contention backs off exponentially and fails with a
* timed-out error after the deadline. The contender never removes an existing
* lock because file age cannot prove that its owner stopped; orphan recovery
* is an operator action. The parent directory must exist.
* @param filename - the file whose writers this lock serializes.
* @param operation - the read-render-commit cycle to run while holding the lock.
* @param options - stale-takeover notification hook.
* @returns the operation's result; the lock releases on both outcomes.
*/
export async function withFileLock<T>(
filename: string,
operation: () => Promise<T>,
options?: WithFileLockOptions,
): Promise<T> {
const lockPath = `${filename}.lock`
const deadline = Date.now() + LOCK_TIMEOUT_MS
@@ -132,17 +104,6 @@ export async function withFileLock<T>(
} catch (error) {
if (!isEEXIST(error)) throw error
}
const ageMs = await lockAgeMs(lockPath)
// The holder released between the failed create and the stat: the lock is
// free right now, so retry without burning backoff or deadline.
if (ageMs === undefined) continue
if (ageMs > LOCK_STALE_MS) {
// TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe
// acquisition and release so a slow writer cannot remove a successor's lock.
options?.onStaleBreak?.(lockPath)
await rm(lockPath, { force: true })
continue
}
if (Date.now() >= deadline) {
throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`)
}