Merge finalized Web transcript parent

# Conflicts:
#	apps/cli/README.i18n.yaml
#	packages/client/runtime/README.i18n.yaml
#	packages/client/ui-trajectory/README.i18n.yaml
#	packages/client/ui-trajectory/README.zh.md
This commit is contained in:
Tianyi Cui
2026-07-31 13:42:47 +08:00
324 changed files with 10708 additions and 1388 deletions

View File

@@ -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-27-web-session-search.md
2026-07-27-web-session-search.md: 9a634c586a4793d1c6986a7e7c0b0c1157b5b687
2026-07-27-web-session-search.zh.md: 5ec2baf7443aaaaa75abc348ee426df9c14fbaa2

View File

@@ -0,0 +1,44 @@
# Agent Note: Web past-session search
Status: implemented
English | [中文](2026-07-27-web-session-search.zh.md)
## Problem
The Web sidebar exposes session titles and Workspace membership but cannot retrieve a past conversation from words that appear only inside its messages. Scanning histories in the browser would require attaching or loading every session, duplicate the existing indexed-search service, and make cold persisted sessions both slow and easy to omit. The product also needs a predictable failure path: an unavailable derived index must not erase title matches that the client can compute locally.
## Decision
The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with `openAt: first-search` and an in-memory database. The service is ACTIVE at boot, while its `node:sqlite` module and connection-private handle open only on the first content query. This keeps Node 22 startup output free of SQLite's experimental warning before search is used without promising to suppress the warning when search first imports the module. Each service instance owns its index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty and lazily reconciles live and persisted sessions on that first query. It remains a disposable derived index, separate from canonical JSONL persistence.
The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. The first provider page requests 20 hits; a first-page `SESSION_QUERY_INVALID_LIMIT` halves that size through 10, 5, 2, and 1, retaining the learned size across continuations and stale-generation restarts. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Emitted snippets contain at most 240 Unicode code points; the Host and wire schema share the protocol bounds and code-point-safe truncation helper, while the wire schema independently enforces the snippet bound at client parse. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. A stale continuation discards the current attempt's partial results, deduplication entries, and cursors, then restarts from the first page against the original visibility snapshot. Limit probes and stale retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider call, and wins over a concurrent limit or stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store.
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event.
The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives beside the response schema that enforces it in `dsh-host-apiproxy`, and `SessionsService.searchResultLimit` re-exposes that constant for presentation plugins. Reaching it from a feature is an explicit widening of the sessions domain: `ISessions` — the face injected as `ctx.sessions`, and therefore what the test runtime's sessions double must implement — declares the search verb next to that bound. The connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound that the schema's fixed `max` forbids, and would leave the same fact with two homes in the same module.
Content matching inherits the SQLite backend's normalized literal token/phrase semantics. The shared semantic projection excludes reasoning blocks, so UI search never returns a model's private reasoning as a hit or snippet; the derived-index schema version advances so existing persistent indexes rebuild without the former documents. FTS5 operators are inert data, and this surface adds no typo, fuzzy, prefix, or arbitrary-substring expansion. In particular, the `unicode61` tokenizer may treat an uninterrupted Chinese sequence as one token, so a shorter query such as `搜索` is not guaranteed to match inside `会话搜索功能`. Title and Workspace matching remains ordinary client-side substring matching.
## Failure and visibility contract
Search never widens session visibility: cold sessions without a servable cwd are absent for the same reason they are absent from `session.list`, and only provider hits whose ids occur in that baseline can leave the Host. Shadowed and log-only events, tool events outside message content, errors, todos, and other trace records do not produce UI hits.
While the first or a later content request is pending, the UI keeps immediate metadata matches and shows a history-search status. If the backend fails, the same rows remain and a warning explains that content search is unavailable. Zero merged rows produce an explicit empty state. More than 20 candidate rows produce a refine-query hint.
## Alternatives considered
- **Scan every session history in the browser** — rejected because it attaches transport and fold cost to the UI, misses cold logs unless they are loaded, and duplicates the semantic extraction and source reconciliation already owned by `ctx.sessionQuery`.
- **Make trigram or fuzzy search part of the first release** — rejected because it changes index size, ranking, short-query behavior, and product expectations. Trigrams also do not by themselves solve two-character queries. The first release uses the existing backend contract and leaves recall expansion as a separate measured decision.
- **Return event addresses and jump to the exact match** — rejected for this release because conversation virtualization and stable event navigation need a separate UI contract. Session-level navigation is useful without coupling search to that work.
- **Expose cursor pagination in the sidebar** — rejected in favor of a fixed top-20 surface and a narrow-query hint; this keeps the interaction and cancellation state bounded.
## Consequences
Past persisted conversations become discoverable without opening them first, while the host retains one visibility boundary and one semantic-index implementation. Immediate local results hide most request latency, cancellation prevents obsolete queries from repainting the list, and backend failure degrades to the behavior available before content search.
The first content query can take longer because it imports and opens SQLite before paying lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. A valid but pathologically unselective or repeatedly stale provider attempt that does not complete within 100 calls takes the metadata-only failure path instead of consuming unbounded work.
## Testing
Host tests pin request and response validation, visible-session filtering, event/surface filters, result and snippet bounds, adaptive provider limits inside the shared call budget, learned-limit stale restarts, cursor and cross-page deduplication behavior, cancellation precedence, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; semantic extraction and SQLite/fixture search tests pin exclusion of reasoning-only text. The Node 22 compatibility gate builds the CLI and Web artifacts, boots the shipped `dsh web`/`AppCLIEntry` composition under plain Node with ambient warning suppression removed and an isolated temporary home/provider environment, waits for settled startup, and disposes it through the shipped signal path. Fixture, runtime, and UI tests pin match-centered bounded snippets, stateless delegation, the 500-code-unit query boundary, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, English copy, ARIA tree membership, row rendering, and navigation semantics. A keyless assembled Web test preserves the lazy-open config while seeding an unopened persisted conversation, finds it by visible message content through the SQLite index, captures the sidebar result, opens it, and verifies that the query remains.

View File

@@ -0,0 +1,44 @@
# Agent Note: Web 历史会话搜索
Status: implemented
[English](2026-07-27-web-session-search.md) | 中文
## 问题
Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只出现在消息中的词语检索历史对话。在浏览器中扫描历史记录,需要附加或加载每个会话,重复实现现有的索引搜索服务,也会让冷态持久化会话的检索既缓慢又容易遗漏。产品还需要一条可预测的故障路径:派生索引不可用时,不得抹去客户端能够在本地计算出的标题匹配结果。
## 决策
Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。服务启动时处于 ACTIVE 状态,而其 `node:sqlite` 模块与连接私有句柄分别要到首次内容查询才会导入和打开。这让 Node 22 的启动输出在使用搜索前不会出现 SQLite 实验性警告,但并不承诺在首次搜索导入该模块时抑制警告。每个服务实例都独占自己的索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动,并在该首次查询时惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。
宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。发出的 snippet 最多包含 240 个 Unicode 码点;宿主与传输 schema 共用协议边界及码点安全的截断辅助函数,而传输 schema 会在客户端解析时独立强制执行 snippet 上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。
结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 位于 `dsh-host-apiproxy` 中强制执行它的响应 schema 旁边,`SessionsService.searchResultLimit` 则把该常量重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明了搜索动作。连接 handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,而 schema 固定的 `max` 恰恰禁止这一点,并且会让同一事实在同一模块内拥有两处归属。
内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。共享语义投影会排除推理(reasoning)块,因此 UI 搜索绝不会将模型的私有推理作为命中或 snippet 返回;派生索引的 schema 版本会随之前进,使现有持久化索引重建并移除先前的这些文档。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。
## 故障与可见性契约
搜索绝不会扩大会话可见范围:没有可供服务的 cwd 的冷会话会被排除,原因与它们不出现在 `session.list` 中相同;只有 id 位于这条基线中的提供方命中才能离开宿主。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。
首个或后续内容请求仍在处理期间,UI 会保留即时元数据匹配结果,并显示历史搜索状态。如果后端失败,这些行会保持不变,并显示警告说明内容搜索不可用。合并后没有任何行时,界面会显示明确的空状态。候选行超过 20 条时,界面会提示用户缩小查询范围。
## 曾考虑的替代方案
- **在浏览器中扫描每个会话的历史记录**:不予采纳,因为这会让 UI 承担传输与折叠开销;除非加载冷态日志,否则还会漏掉这些日志;并会重复实现已经由 `ctx.sessionQuery` 负责的语义提取与源对齐。
- **首版即加入 trigram 或模糊搜索**:不予采纳,因为这会改变索引大小、排序、短查询行为与产品预期。trigram 本身也无法解决双字查询。首版沿用现有后端契约,将召回扩展留作另一项基于度量结果的决策。
- **返回事件地址并跳转至确切匹配位置**:本版不予采纳,因为对话虚拟化与稳定的事件导航需要单独的 UI 契约。会话级导航本身已有价值,无需让搜索与这项工作耦合。
- **在侧边栏公开游标分页**:不予采纳,改为固定显示前 20 条结果并提示缩小查询范围;这样可使交互与取消状态保持有界。
## 后果
无需预先打开,即可检索到历史持久化对话,同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟,取消机制可防止已作废查询重新渲染列表,后端故障则会降级为内容搜索尚不可用时已有的行为。
首次内容查询可能耗时更长,因为它要先导入并打开 SQLite,再承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。如果有效但选择性极差或反复陈旧的提供方尝试未能在 100 次调用内完成,系统会进入仅保留元数据匹配的故障路径,而不是无限制地继续处理。
## 测试
宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为契约。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为契约;语义提取测试与 SQLite/fixture 搜索测试将排除仅存在于推理中的文本固定为契约。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。

View File

@@ -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-diff-card.md
2026-07-30-web-diff-card.md: 396bdbc2843c1bbed5c6a913be436d8b9e96a81c
2026-07-30-web-diff-card.zh.md: afdeafa6e94b46b4f0fbd4a065afdac8a93ac57d

View File

@@ -0,0 +1,57 @@
# Agent Note: Web diff card — the write/edit render intent reaches the browser
Status: implemented
English | [中文](2026-07-30-web-diff-card.zh.md)
## Problem
The `write` and `edit` tools declare `card: 'diff'` for both their call and their result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the intended change derived from the arguments, and the result view carries the applied contextual hunks (`FileDiff[]`, computed by `packages/fs/tool-fs/src/diff.ts` and persisted in the result `meta` so replay reproduces it). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the TUI already renders it as per-file `+`/`-` blocks with a `+A -R · N file(s)` footer.
The Web client ignored it. A write/edit call landed on `GenericToolCard`, whose row is derived from raw tool args, and the details panel flattened the result's content blocks into one `<pre>`. The `diffs` payload — the whole point of the result — was discarded, so a file mutation read as a one-line confirmation with no visible change.
This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` arm: that change made the Web client a consumer of the `terminal` render intent; this one makes it a consumer of the `diff` render intent, reusing the same four-layer shape.
## Decision
`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends:
- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both.
- **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side.
- **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends.
- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry.
- **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable.
Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer.
The chat row renders the diff resident under its path-link summary, capped at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 — the same inline-output decision and the same in-flow-vs-reading-surface split recorded for the [terminal card](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention). A write/edit row is single-file, so its summary stays an openable path link AND its diff card expands; the two coexist because the card is not the path's args body.
## Alternatives considered
**A side-by-side (two-column) diff.** Rejected for now by the owner: it is denser but does not fit the narrow chat row, and the goal was parity with the TUI's single-column unified form. A two-column mode in the details panel is a later props change, not a redesign.
**Git-style line-number gutters.** The `FileDiff` contract carries only `{ path, oldText, newText }` — `structuredPatch`'s hunk start lines are dropped in `diff.ts`, so no line number reaches the client. Rendering a numbered gutter needs a backend contract change (carry `oldStart`/`newStart`) and a matching TUI upgrade to stay consistent; deferred so this PR stays a pure Web consumer of the existing contract.
**Reuse `CodeBlock`.** Rejected for the same reason the terminal card was: `CodeBlock` soft-wraps and has no per-line `+`/`-` role, no path headers, and no footer. The two share geometry and font tokens, which is the only part where one implementation is correct for both.
## Consequences
`DiffBlock` reads only the diff view's fields, so it stays a pure function of what the render intent carries — replay-safe like the presenters that produce the view. A UI without the diff capability still gets the bridge's generic fallback; nothing about the tool's result shape changed. No new runtime dependency: unlike the terminal card's `anser`, a diff needs no parser.
The multi-file arm of `DiffBlock` (one card, several path headers) has no producer today: `write`/`edit` each mutate one file per call, so a real card shows one file with one or more hunks. The arm is built and tested for a future multi-file mutation tool, not for a current consumer.
## Testing
`packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%.
`packages/client/ui-conversation/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section.
The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns.
## Related
- [Web terminal card](2026-07-28-web-terminal-card.md) — the same four-layer shape for the `terminal` arm; this note reuses its inline-output decision and its head/tail cap arithmetic.
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this consumes; the Web client is now a consumer of the `diff` arm too.
- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md) — the slot and snapshot layering the two render sites sit in.

View File

@@ -0,0 +1,57 @@
# Agent Note: Web diff 卡片 —— write/edit 渲染意图抵达浏览器
Status: implemented
[English](2026-07-30-web-diff-card.md) | 中文
## Problem
`write` 和 `edit` 工具为其 call 和 result 都声明了 `card: 'diff'`([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)):call view 携带从参数推导的预期改动,result view 携带已应用的上下文 hunk(`FileDiff[]`,由 `packages/fs/tool-fs/src/diff.ts` 计算,并持久化在 result `meta` 中以便回放重建)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `callView`/`resultView` 投递到 `ConversationSnapshot` —— TUI 也已将其渲染为按文件分组的 `+`/`-` 块加 `+A -R · N file(s)` 页脚。
Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行从原始工具参数推导,详情面板把 result 的 content block 摊平进一个 `<pre>`。`diffs` 载荷 —— result 的全部意义 —— 被丢弃,于是一次文件改动读起来只是一行确认、看不到任何改动。
这是把 [terminal 卡片](2026-07-28-web-terminal-card.md) 对 `diff` 这一支重做一遍:那次改动让 Web 客户端成为 `terminal` 渲染意图的消费者;这次让它成为 `diff` 渲染意图的消费者,复用同一套四层结构。
## Decision
`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
组件的契约遵循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态:
- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚在两个前端都统计**去重后的路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。
- **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。
- **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。
- **行终止符。** 每一侧的内容按 `TerminalBlock` 的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。本 PR 把同一规则应用到了 TUI diff 分支,因此对于真实 write/edit 调用携带的以换行结尾的内容,两个前端的 `+A -R` 页脚计数一致。
- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。
几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。
chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX_LINES`(8),对应面板的 16 —— 与 [terminal 卡片](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention)记录的内联输出决策、以及流内表面对单调阅读表面的同一划分一致。write/edit 行是单文件的,所以它的摘要既是可打开的路径链接,其 diff 卡片又展开;两者共存,因为卡片不是路径的参数体。
## Alternatives considered
**并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。
**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR 保持为对既有契约的纯 Web 消费。
**复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token,那是唯一一处一个实现对两者都正确的部分。
## Consequences
`DiffBlock` 只读 diff view 的字段,因此它是渲染意图所携带内容的纯函数 —— 与产出该视图的 presenter 一样回放安全。没有 diff 能力的 UI 仍得到 bridge 的通用回退;工具的 result 形状没有任何改变。无新增运行时依赖:不同于 terminal 卡片的 `anser`,diff 不需要解析器。
`DiffBlock` 的多文件支路(一张卡、多个路径头)今天没有生产者:`write`/`edit` 每次调用各改一个文件,所以真实卡片显示一个文件带一个或多个 hunk。该支路为将来的多文件改动工具而构建并测试,不是为当前消费者。
## Testing
`packages/client/ui-primitives/tests/diff-block.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。
`packages/client/ui-conversation/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。
fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身契约它不带 diff 行为断言,那由接线套件负责。
## Related
- [Web terminal 卡片](2026-07-28-web-terminal-card.md) —— `terminal` 支路的同一套四层结构;本 note 复用其内联输出决策与头尾上限算术。
- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— 本改动消费的 `card` 标签词汇;Web 客户端现在也是 `diff` 支路的消费者。
- [Web 客户端架构](../architecture/2026-07-19-gui-web-client-architecture.md) —— 两个渲染点所处的 slot 与快照分层。

View File

@@ -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

View File

@@ -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.

View File

@@ -0,0 +1,49 @@
# Agent Note: Web result 卡片前端 —— 在浏览器渲染 web 渲染意图
Status: implemented
[English](2026-07-30-web-result-card-frontend.md) | 中文
## Problem
`web_search` 和 `web_fetch` 工具声明了 `card: 'web'` result view([web result card](2026-07-30-web-result-card.md)):一个 `kind` 标签联合,携带结构化的被引用 sources 加可选的 provider answer(`kind: 'search'`),或抓取的 URL 及其 HTTP 状态(`kind: 'fetch'`)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `resultView` 投递到 `ConversationSnapshot` —— 但 Web 客户端忽略了它:一次已完成的 web 调用只渲染为摊平的模型可见文本,正是契约笔记所解释的、结构化视图要替代的那种有损渲染。`web_search` 到达读者时是每个 source 一行自由文本 markdown,而非可点击 source 的引用列表;`web_fetch` 是它的 markdown 正文,没有检索摘要。
## Decision
`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。
一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。
**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集。** MarkdownText 还允许 `mailto:`,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:`/`mailto:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。
**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。
卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。
## Consequences
`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到契约的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。
一条独立的后续 PR 会统一整行折叠/展开交互,并把每张常驻卡片(terminal、diff、web)一次性翻成 expand-gated;本卡片遵循当前的常驻约定,而非抢先做那次改动。
## Alternatives considered
**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而契约已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。
**重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。
**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还允许 `mailto:`)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。
## Testing
`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。
`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。
fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。
## Related
- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。
- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。
- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。

View File

@@ -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-tool-row-unified-expand-and-inspect.md
2026-07-30-web-tool-row-unified-expand-and-inspect.md: ba2f4ead8023772fad578ca0b647241ecc332905
2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: ac4835c7429a3ff7d3042f73d26d267911533132

View File

@@ -0,0 +1,34 @@
# Agent Note: Web tool-row unified expand and trajectory Inspect
Status: implemented
English | [中文](2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md)
## Problem
The chat view's tool rows had drifted into per-surface interaction dialects: ToolRow expanded through a leading-icon toggle and only for calls with an args body, the bash sample had its own expand affordance, todo/ask-question rows expanded raw args only, single-file tools were not expandable at all, and a call's OUTPUT was reachable only through the details panel. A failing bash command (exit≠0 settles `isError:false`) showed no collapsed-row failure signal. There was also no path from a chat row to its trajectory record, and switching chat → trajectory → chat lost the reader's scroll position because the tab ring unmounts inactive views.
## Decision
**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its scroll offset across view switches through an in-memory per-session map.**
- `toolRowModel` now derives result material alongside args: `output` (the `resultText` flatten, moved from DetailsPanel into the contract), and `errorSummary` (the failure's first line, shown as the collapsed summary in the error color). A row with body, output, or terminal material is expandable; the row itself is the toggle (`role="button"`, `aria-expanded`), and file-path summaries stay independent links via `stopPropagation`.
- The expanded card (figma 1249:35657) is a column of IN/OUT sections: each section is its own scrollport (max-height 150px) with a sticky gutter label, and the l2 divider spans the full card width. Think prose and the run_code CodeBlock keep their non-card bodies; context injection reuses the row with a label-less `plainBody` card.
- `terminalFailed` reads a settled terminal card's exit status so BashRow and GenericToolCard surface a failing command as the row's red state dot — the only failure signal the collapsed row has, since the call itself settles `isError:false`.
- TerminalBlock's banner joins the same reading model: it shares the card surface (no banner token), an l2 hairline separates it from the body, the command column caps at 150px and scrolls with sticky copy/status controls top-aligned to the first prompt row.
- Inspect: `ToolRowOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field.
- Scroll preservation: the chat view saves its offset on every scroll (null when pinned to bottom) into an apply-scope `Map<SessionId, number>` exposed as `chatScroll` on the injected props; the open-jump branch restores it on remount. Deliberately not persisted — a fresh page load keeps the open-jump-to-bottom default.
## Alternatives considered
**Keeping the leading-icon toggle and per-registrant expand affordances.** Rejected: three surfaces had already diverged; the registrant posture (bash sample replicates CSS locally) makes drift permanent unless the interaction contract itself is uniform and small — whole-row toggle plus hover preview.
**Routing Inspect through a URL or a trajectory-view prop.** Rejected: the view ring renders through the slot registry, so the two views share no parent that could carry a prop; the chat store already crosses that boundary and the one-shot field keeps the handoff replay-safe (persisted snapshots from before the field rehydrate with `?? null`).
**Persisting the chat scroll offset.** Rejected: restoring a days-old offset into a conversation that has since grown reads as a bug; the in-memory map scopes the memory to exactly the view-switch case that loses it.
**A per-row expanded OUTPUT fetched from the details panel's material.** Unnecessary: the settled result node already rides the snapshot's frozen call slice, so the contract-level `resultText` flatten serves both the row and the panel from one derivation.
## Consequences
Any registered toolview gets input AND output inspection in place, with the details panel and trajectory remaining the deep-dive surfaces. The unified interaction is contract-visible (`ToolRowProps.output/errorSummary/inspect`), so third-party rows opt in by passing model fields through. The bash sample intentionally re-replicates the new CSS (registrant posture), so future interaction changes still touch it by hand. `--dsw-font-markdown-code-block-small` (12/18) is a hand-added token pending a design-platform export. The web-cordis `distIndex` fix (plain concatenation, not URL.pathname) unblocks preview boots from a cwd with spaces.

View File

@@ -0,0 +1,34 @@
# Agent Note:Web 工具行统一展开交互与 trajectory Inspect
状态:已实现
[English](2026-07-30-web-tool-row-unified-expand-and-inspect.md) | 中文
## 问题
聊天视图的工具行交互已经分裂成多种方言:ToolRow 通过前导图标切换展开、且仅限有 args body 的调用,bash 示例有自己的一套展开方式,todo / ask-question 行只能展开原始 args,单文件工具完全不可展开,而调用的 OUTPUT 只能通过右侧详情面板查看。失败的 bash 命令(exit≠0 但结算为 `isError:false`)在折叠行上没有任何失败信号。此外聊天行没有跳转到 trajectory 记录的入口,且 chat → trajectory → chat 切换会丢失阅读位置(标签环会卸载非活跃视图)。
## 决定
**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留滚动位置。**
- `toolRowModel` 在 args 之外同时派生结果材料:`output`(`resultText` 拍平逻辑从 DetailsPanel 移入 contract)和 `errorSummary`(失败首行,以错误色作为折叠摘要)。有 body、output 或 terminal 材料的行即可展开;行本身是开关(`role="button"`、`aria-expanded`),文件路径摘要通过 `stopPropagation` 保持独立链接。
- 展开卡片(figma 1249:35657)是 IN/OUT 分区列:每个分区是独立滚动区(max-height 150px),侧栏标签 sticky 固定,l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。
- `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`。
- TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token),与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。
- Inspect:`ToolRowOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。
- 滚动保留:聊天视图在每次滚动时保存偏移(贴底时为 null)到 apply 作用域的 `Map<SessionId, number>`,经注入 props 的 `chatScroll` 暴露;重挂载时 open-jump 分支恢复它。刻意不持久化——新页面加载保持打开即贴底的默认行为。
## 曾考虑的替代方案
**保留前导图标开关和各注册方自有的展开方式。** 否决:三个表面已经分化;注册方姿态(bash 示例本地复刻 CSS)意味着除非交互契约本身统一且足够小——整行开关加 hover 预览——否则漂移会永久存在。
**通过 URL 或 trajectory 视图 prop 传递 Inspect。** 否决:视图环经由 slot 注册表渲染,两个视图没有可携带 prop 的共同父级;chat store 本就跨越该边界,一次性字段让交接可安全重放(字段出现之前的持久化快照以 `?? null` 复水)。
**持久化聊天滚动偏移。** 否决:把几天前的偏移恢复到已经增长的会话里读起来像 bug;内存 Map 把记忆精确限定在会丢位置的视图切换场景。
**从详情面板的材料为每行单独取展开 OUTPUT。** 不必要:已结算结果节点本就在快照的冻结调用切片上,contract 层的 `resultText` 拍平让行和面板共用一份派生。
## 后果
任何已注册 toolview 都能就地查看输入与输出,详情面板和 trajectory 仍是深查表面。统一交互契约可见(`ToolRowProps.output/errorSummary/inspect`),第三方行透传模型字段即可接入。bash 示例有意重新复刻新 CSS(注册方姿态),未来交互变更仍需手动同步它。`--dsw-font-markdown-code-block-small`(12/18)是手工补充的 token,待设计平台导出后替换。web-cordis 的 `distIndex` 修复(纯拼接而非 URL.pathname)解除了含空格 cwd 下预览无法启动的问题。

View File

@@ -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-31-gui-full-access-confirmation.md
2026-07-31-gui-full-access-confirmation.md: 8208a20bee9b9ab8f1e73720790e3e5be4c27306
2026-07-31-gui-full-access-confirmation.zh.md: 8ac115034f562fe96d53bdba010b05648d4d5947

View File

@@ -0,0 +1,30 @@
# Agent Note: GUI Full access risk confirmation
Status: implemented
English | [中文](2026-07-31-gui-full-access-confirmation.zh.md)
## Problem
Switching the web client to `danger-full-access` was a single click on either permission surface (the composer's Access chip and the `/permission` popup picker), with the preset shown as the title-cased machine name `Danger Full Access`. Full access reduces confirmation steps and lets the agent run sensitive operations, modify files, or execute external commands, so an accidental pick armed the most dangerous preset with no deliberate acknowledgement step.
## Decision
**Both permission surfaces gate `danger-full-access` behind one shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
- `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
- The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
- The `/permission` popup (ui-permission over the ui-command shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
- `Full access` intentionally overrides the kebab-to-title display transform on both surfaces (option rows, trigger label, settled command rows keep the machine name on the wire); the warning body remains locale-aware in Chinese and English.
## Alternatives considered
**A native/OS or separate-window confirmation.** Rejected: the dialog must stay inside the current WebUI window; a second window can appear on another display and detaches the decision from the page state it guards.
**One shared locale namespace for both surfaces' safety copy.** Rejected: the ui-permission bundle and ui-conversation load independently, so each registers the same copy under its own namespace (`permission.access` beside the conversation dictionary); the duplication is fenced with an explanatory `jscpd:ignore` block rather than a cross-bundle import.
**Gating in the host/permission backend.** Out of scope by design: the change is browser-client confirmation flow only; backend permission semantics, defaults, and the safer presets' one-click behavior are unchanged.
## Consequences
Every visible GUI path into Full access now requires a deliberate, informed acknowledgement, at the cost of one extra dialog step for users who genuinely want the preset. New pickers reuse the gate by attaching a `confirmation` payload (popup path) or the chip's state machine (composer path) instead of inventing bespoke dialogs. Acceptance: the composer flow's four gated cases in `input-bar.spec.tsx`, the popup gate in `popup-view.spec.tsx` and `popup.spec.ts`, the Modal/RiskConfirmation contract in `atoms.spec.tsx`, and the assembled `access-confirmation` web e2e whose golden pins the product-default Chinese dictionary copy.

View File

@@ -0,0 +1,30 @@
# Agent Note: GUI Full access 风险确认
Status: implemented
[English](2026-07-31-gui-full-access-confirmation.md) | 中文
## Problem
Web 客户端切换到 `danger-full-access` 在两个权限面(编辑器的 Access chip 与 `/permission` popup 选择器)上都只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
## Decision
**两个权限面都把 `danger-full-access` 关进同一个共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不提交任何命令。**
- `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` 座位,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
- 编辑器 chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale 座位以 `access.confirm.*` 键供给。
- `/permission` popup(ui-permission 骑在 ui-command 外壳上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
- `Full access` 在两个面上有意覆盖 kebab 转 Title Case 的显示变换(选项行、触发器标签;落定的命令行仍在 wire 上保留机器名);警示正文保持中英文 locale 感知。
## Alternatives considered
**原生/操作系统或独立窗口确认。** 已拒:对话框必须留在当前 WebUI 窗口内;第二个窗口可能出现在另一块显示器上,使决策脱离其守护的页面状态。
**两个面共享一个安全文案 locale namespace。** 已拒:ui-permission bundle 与 ui-conversation 可独立加载,故各自在自己的 namespace 下注册同一份文案(`permission.access` 与 conversation 词典并立);这处重复以带说明的 `jscpd:ignore` 块圈护,而非跨 bundle import。
**在 host/权限后端把关。** 设计上即出界:本变更只涉浏览器客户端确认流;后端权限语义、默认值与更安全预设的一键行为均不变。
## Consequences
进入 Full access 的每条可见 GUI 路径现在都要求刻意且知情的确认,代价是真想启用该预设的用户多一步对话框。新的选择器复用此门:popup 路径挂 `confirmation` 载荷、编辑器路径走 chip 的状态机,而不是各造对话框。验收:`input-bar.spec.tsx` 中编辑器流的四个门控用例、`popup-view.spec.tsx` 与 `popup.spec.ts` 的 popup 门、`atoms.spec.tsx` 的 Modal/RiskConfirmation 契约,以及组装态 `access-confirmation` web e2e——其 golden 钉住产品默认中文词典文案。