Merge origin/master at 52ec34f796
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
|
||||
2026-07-28-web-terminal-card.md: 14896b1d88e5cfd2e4c58830c7a1bca1e54ed823
|
||||
2026-07-28-web-terminal-card.zh.md: 16c9004f8f80b720b25b76ba5c04f308b0fccbaf
|
||||
2026-07-28-web-terminal-card.md: 0e5f3e2157ebfc4e71aead26c15b6ee91958a5d5
|
||||
2026-07-28-web-terminal-card.zh.md: 1285d3fbb46ebd32ff163feac632cd487e8a04f1
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-07-28-web-terminal-card.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The bash tool declares `card: 'terminal'` for both its call and its result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the command, an optional model-authored description, and the working directory; the result view carries the output, exit code, and terminating signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the TUI already renders it as a `$`-prompt card with an exit line and a head/tail height cap.
|
||||
The bash tool declares `card: 'terminal'` for both its call and its result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the command, an optional model-authored description, and the working directory; the result view carries the output, exit code, and terminating signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the former TUI rendered it as a `$`-prompt card with an exit line and a head/tail height cap.
|
||||
|
||||
The Web client ignored it. `packages/client/ui-conversation/src/client/contract/tool-call-model.ts` derived every row from raw tool args, and `skeleton/DetailsPanel.tsx` flattened every tool's content blocks into one `<pre>` with `white-space: pre-wrap; word-break: break-word`. Two defects followed from soft-wrapping and from having no height bound: multi-column output (`ls`, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.
|
||||
|
||||
@@ -19,7 +19,7 @@ The component's contract:
|
||||
- **Prompt lines, one per command line.** Each line of the command gets its own row: label, then that line verbatim. A `command` carrying two shell commands on two lines therefore reads as the two commands it is, instead of collapsing into one ellipsized row. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. A trailing newline is a terminator, not an empty final command. Only the FIRST row carries the label: the view knows one working directory — where the call started — and a later line may run somewhere else entirely, since a `cd` in the command is enough to move it. Repeating the label down the rows would state a directory per line that nothing here knows, which is the same reason the run-state dot appears once. Later rows keep a bare `$` so they still read as prompts.
|
||||
- **One run-state dot for the call, on the first row.** `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. The dot exists because the first question a reader has about a shell command is whether it is still running, and without it that had to be inferred from the absence of output — which a settled command producing no output also looks like. It sits out of flow in a gutter the card reserves as its OWN left padding, so it neither indents its command nor depends on the command's text metrics to line up. The reservation is padding rather than margin because every render site rewrites `margin` wholesale to set its own indent, which silently cancelled a margin-based gutter and let a container clip the dot. Exactly one dot, whatever the line count: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per line would assert of a line that succeeded inside a failing call that the line itself failed. The single visually hidden text label carries the same scope, since `StateDot` is `aria-hidden` and one label per row would read to assistive technology as several distinct outcomes.
|
||||
- **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding.
|
||||
- **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends.
|
||||
- **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic preserves the former TUI transcript's collapsed-card behavior, so the established head/tail selection stays stable.
|
||||
- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters. Cursor movements resolve before that strip, into a per-line column buffer rather than by string surgery, because carriage return and backspace only MOVE the cursor — neither erases anything, so what a reader sees is whatever each column last had written to it. `100%` then a carriage return and `OK` shows `OK0%`, since the redraw is shorter than the frame beneath it; a trailing `abc` plus a backspace still shows `abc`, since nothing overwrote the `c`; `abc` plus two backspaces and `XY` shows `aXY`. Each of these was checked against a real terminal, because the earlier truncate-and-delete approximations looked right and were not. SGR state is stamped per column as a terminal stores it per cell, so a partial overwrite keeps each surviving character's own color: red `bad`, three backspaces, then `ok` shows `okd` with the `d` still red. A CSI sequence occupies no column and changes only the state later writes are stamped with, which is also why a carriage return does not reset color, and why SGR state threads from one line to the next rather than closing at each newline. Erase-in-line is part of the same replay, because `\r\x1b[K` is the single idiom every spinner and progress bar writes — modelling the `\r` alone left the previous frame's tail standing, which is text the terminal never showed. Only `m` accumulates into a cell's style; a cursor or erase sequence must not, or the state string grows per redraw and emits boundaries anser has to discard. SGR is held per cell as a NORMALIZED record (foreground, background, attribute set), not as the sequence history: accumulating raw sequences made every state boundary re-emit the whole chain, so output that switches color without a full reset emitted O(n^2) characters — 3200 such cells produced 25 MB and a `RangeError` well under bash's own output cap. The record also lets the attribute closers every chalk-based tool writes (`39`, `49`, `22`, `24`, …) actually close their attribute, and each boundary emits one canonical sequence for the state it opens. A run also has to CLOSE: the replay converges to the state the scan ended in, not the last written cell's, because a reset after the final write changes no cell yet ends the run — without that a line finishing in `\x1b[0m` leaked its color onto every later line. The cursor advances by terminal columns, so a tab reaches the next 8-column stop, a wide character takes two (its spacer blanking rather than closing the gap once the lead cell is overwritten), and a combining mark takes none. Width follows emoji PRESENTATION rather than the U+2600-U+27BF block: `\u2713`, the check every progress line writes, is one column, so treating the block as wide misaligned exactly the output this card exists for. Writing over either half of a wide pair blanks the other, since a terminal cannot leave one cell of a two-cell glyph standing: `a\tb` then a redraw of `XY` shows `XY b`, since a two-character redraw cannot reach column 8.
|
||||
- **Exit status and copy.** A non-zero exit code or a signal renders a status pill, matching the exit-status distinction the bash tool's own renderer draws; a clean exit renders none, and settled empty output renders a dimmed placeholder — judged on the parsed lines the card renders, not on the raw text, since output that is only escapes or control bytes survives a `trim()` yet parses to nothing visible and would otherwise draw blank rows plus a copy control for invisible bytes. The copy control copies the raw output text, not the rendered tree, so the prompt line and the pill stay out of the clipboard.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
bash 工具的调用与结果都声明 `card: 'terminal'`([渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md)):调用视图携带命令、一段可选的模型撰写描述以及工作目录,结果视图携带输出、退出码与终止信号。该视图早已抵达浏览器——host、connection 与 runtime 把它投递到 `ConversationSnapshot` 的 `callView`/`resultView` 上——TUI 也早已把它渲染为带 `$` 提示符的卡片,附退出行与首尾高度上限。
|
||||
bash 工具的调用与结果都声明 `card: 'terminal'`([渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md)):调用视图携带命令、一段可选的模型撰写描述以及工作目录,结果视图携带输出、退出码与终止信号。该视图早已抵达浏览器——host、connection 与 runtime 把它投递到 `ConversationSnapshot` 的 `callView`/`resultView` 上——原 TUI 曾把它渲染为带 `$` 提示符的卡片,附退出行与首尾高度上限。
|
||||
|
||||
Web client 却对它视而不见。`packages/client/ui-conversation/src/client/contract/tool-call-model.ts` 仅从原始工具参数推导每一行,`skeleton/DetailsPanel.tsx` 则把所有工具的内容块压平进一个 `<pre>`,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
|
||||
|
||||
@@ -19,7 +19,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c
|
||||
- **提示符行,每条命令行一行。** 命令的每一行各占一行:标签,其后原样跟随该行。因此一个在两行上承载两条 shell 命令的 `command` 就读作它本身的两条命令,而不是被压成一行并省略号截断。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。末尾换行是终止符,不是一条空的末命令。只有**第一行**携带该标签:视图只知道一个工作目录——调用开始处的那个——而后面的行完全可能在别处运行,命令里一个 `cd` 就足以改变它。把标签在各行重复,等于陈述一个此处无人知晓的逐行目录,这与运行状态点只出现一次是同一个理由。其余行保留一个裸 `$`,因此它们仍读作提示符。
|
||||
- **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片以**自身左内边距**预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。该预留用 padding 而非 margin,是因为每个渲染点都会整条重写 `margin` 来设定自己的缩进——那会静默取消基于 margin 的落区,并让容器把状态点裁掉。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot` 是 `aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。
|
||||
- **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。
|
||||
- **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。
|
||||
- **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法保留原 TUI transcript 折叠卡片的行为,因此既有的首尾选择保持稳定。
|
||||
- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。光标移动在该剥除之前先行结算,且落在逐行的列缓冲里而不是靠字符串手术,因为回车与退格**只移动**光标——两者都不擦除任何东西,所以读者看到的就是每一列最后被写入的内容。`100%` 后接回车再接 `OK` 显示为 `OK0%`,因为这次重绘比它下面的帧更短;末尾 `abc` 加一个退格仍显示 `abc`,因为没有任何东西覆盖过那个 `c`;`abc` 加两个退格再接 `XY` 显示 `aXY`。这些用例都对照真实终端核实过,因为先前「截断加删除」的近似看起来是对的,实际并不对。SGR 状态按列打戳,与终端按单元格存储颜色的方式一致,因此部分覆盖会保留每个存活字符自身的颜色:红色 `bad`、三个退格、再写 `ok`,显示为 `okd` 且那个 `d` 仍是红的。CSI 序列不占列,只改变后续写入被打上的状态——这也正是回车不会重置颜色的原因,以及 SGR 状态会从一行延续到下一行、而不是在每个换行处关闭的原因。行内擦除属于同一次重放,因为 `\r\x1b[K` 是每个 spinner 与进度条都会写的同一个惯用法——只建模 `\r` 会让上一帧的尾巴留在原处,那是终端从未显示过的文本。只有 `m` 会累加进单元格样式;光标或擦除序列不能累加,否则状态串会随每次重绘线性增长,并发出 anser 只能丢弃的边界。SGR 按单元格以**归一化记录**保存(前景、背景、属性集合),而不是序列历史:累积原始序列会让每个状态边界重新发射整条链,因此不做完整 reset 的换色输出会发射 O(n^2) 个字符——3200 个这样的单元格产生 25 MB 并最终 `RangeError`,远低于 bash 自身的输出上限。该记录也让所有 chalk 系工具写出的属性闭合码(`39`、`49`、`22`、`24` 等)真正闭合其属性,且每个边界只为它开启的状态发射一条规范序列。一个分段也必须**收束**:重放收敛到扫描结束时的状态,而不是最后一个被写入单元格的状态——因为最后一次写入之后的 reset 不改变任何单元格,却结束了该分段;没有这一步,以 `\x1b[0m` 结尾的行会把颜色泄漏到其后所有行。光标按终端列推进,因此制表符前进到下一个 8 列制表位、宽字符占两列(其续列在首列被覆盖后变为空白而非合拢),组合标记不占列。宽度依据 emoji **presentation** 而非 U+2600–U+27BF 整个区块:`\u2713`——每条进度行都会写的对勾——只占一列,把该区块整体当作双宽恰好会错位这张卡片赖以存在的那类输出。写入宽字符对的任一半都会把另一半清成空白,因为终端无法让一个双格字形只留下一格:`a\tb` 之后用 `XY` 重绘显示为 `XY b`,因为两个字符的重绘到不了第 8 列。
|
||||
- **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字——该判定读的是卡片实际渲染的解析行,而非原始文本,因为只含转义或控制字节的输出能通过 `trim()` 却解析不出任何可见内容,否则就会画出一片空行外加一个把不可见字节写进剪贴板的复制控件。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-search-render-card.md
|
||||
2026-07-30-search-render-card.md: 36f772d7198ef30d6c243549cbaa9f16c780268b
|
||||
2026-07-30-search-render-card.zh.md: 7d7ba352f19f3fb83cb6b7d049980776dc2c277e
|
||||
2026-07-30-search-render-card.md: 29544cb703f3ab048f4e7702935887ecf05179bf
|
||||
2026-07-30-search-render-card.zh.md: e0ae21924e82152ba629ab628476113ad9afe3d8
|
||||
|
||||
@@ -18,7 +18,7 @@ The discriminant is `shape`, not `kind`, deliberately: the same presentation mod
|
||||
|
||||
One view with two shapes rather than two cards, because both tools are the same visual object — a search result — and a web consumer switches on one `card` value, then on `shape` for the row layout. The discriminated `shape` keeps each variant's fields non-optional (a matches view always has `files`, a paths view always has `paths`) instead of a single interface where every shape-specific field is optional.
|
||||
|
||||
The view carries **no** result text. An earlier revision attached the model-facing `result.content` to the view; that was a no-op for every consumer (the TUI already falls back to `result.content`, and web fallbacks read the raw `tool/result` content), and it serialized the whole search text a second time into the persisted view. The view is the structured shape only; a UI without a search card falls back to the raw `tool/result` content.
|
||||
The view carries **no** result text. An earlier revision attached the model-facing `result.content` to the view; that was a no-op because consumer fallbacks already read the raw `tool/result` content, and it serialized the whole search text a second time into the persisted view. The view is the structured shape only; a UI without a search card falls back to the raw result content.
|
||||
|
||||
The card tag is result-time only. A search call stays a `GenericCallView` (`kind: 'search'`): the pending state has no matches or paths to show, so there is nothing a `SearchCallView` would carry that the generic title does not. This is the asymmetry with the terminal card, whose call view carries the command, cwd, and description that exist before execution; a search's structured content exists only after `execute`.
|
||||
|
||||
@@ -30,7 +30,7 @@ The card tag is result-time only. A search call stays a `GenericCallView` (`kind
|
||||
|
||||
The `SearchMeta` member shapes are object-literal `type` aliases, not the `SearchFileMatches`/`SearchLineMatch` interfaces the view exposes, because only a type alias is assignable to the `JsonValue` index signature `presentationMeta` returns; the two are structurally identical, so the projected value still reads back as a `SearchResultView`.
|
||||
|
||||
The TUI (`packages/ui/tui/src/components/transcript.ts`) needs no dedicated arm: its result-view switch handles `terminal` and `diff` explicitly, and a `search` view falls through to the same dim generic body, reading the model-facing text from `this.result?.content`. Because the search view carries no `content` of its own and grep/glob returned a generic card before this PR, the TUI output stays byte-identical to the pre-search-card fallback. The web frontend that renders the structured `files`/`paths` shape is a separate later PR; this PR is the backend contract and its two producers.
|
||||
A consumer without a dedicated `search` arm falls back to the same generic body and reads the model-facing text from the raw result. Because the search view carries no `content` of its own and grep/glob returned a generic card before this PR, that fallback stays byte-identical to the pre-search-card path. The frontend that renders the structured `files`/`paths` shape is independent of this backend contract and its two producers.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -48,7 +48,7 @@ The TUI (`packages/ui/tui/src/components/transcript.ts`) needs no dedicated arm:
|
||||
|
||||
`grep` and `glob` now compute `presentationMeta` on every non-nested successful call, a bounded projection over the already-retained matches or paths — the same retention outcome the render consumes, so there is no second retention pass and no doubled search text on the wire. The serialized meta is bounded by `searchMetaMaxBytes`, so a broad search no longer persists an unbounded structured copy into the session log.
|
||||
|
||||
A UI without a search card renders the raw `tool/result` content, so no consumer regresses, and the TUI stays byte-identical. The web consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained, byte-bounded page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does.
|
||||
A UI without a search card renders the raw `tool/result` content, so no consumer regresses. A consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained, byte-bounded page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: implemented
|
||||
|
||||
用一个带两种形状的视图而非两张卡片,因为两个工具是同一个视觉对象 —— 一个搜索结果 —— web 消费方先在一个 `card` 值上分支,再在 `shape` 上分支决定行布局。判别式 `shape` 让每个变体的字段保持非可选(matches 视图总有 `files`,paths 视图总有 `paths`),而不是一个所有形状相关字段都可选的单一接口。
|
||||
|
||||
该视图**不**携带结果文本。早期版本曾把面向模型的 `result.content` 附到视图上;那对每个消费方都是 no-op(TUI 本就回退到 `result.content`,web 回退读原始 `tool/result` 内容),却把整段搜索文本又序列化进持久化视图一遍。视图只承载结构化形状;无 search 卡片的 UI 回退到原始 `tool/result` 内容。
|
||||
该视图**不**携带结果文本。早期版本曾把面向模型的 `result.content` 附到视图上;但消费方的回退路径本就读取原始 `tool/result` 内容,因此这不会产生效果,却会把整段搜索文本又序列化进持久化视图一遍。视图只承载结构化形状;无 search 卡片的 UI 回退到原始结果内容。
|
||||
|
||||
卡片标签只在结果时存在。搜索调用保持为 `GenericCallView`(`kind: 'search'`):pending 状态没有匹配或路径可展示,所以 `SearchCallView` 能携带的东西不会比 generic 标题更多。这是与 terminal 卡片的不对称之处 —— terminal 的调用视图携带执行前就存在的命令、cwd、description;搜索的结构化内容只在 `execute` 之后才存在。
|
||||
|
||||
@@ -30,7 +30,7 @@ Status: implemented
|
||||
|
||||
`SearchMeta` 的成员形状是对象字面量 `type` 别名,而非视图暴露的 `SearchFileMatches`/`SearchLineMatch` 接口,因为只有 type 别名可赋给 `presentationMeta` 返回的 `JsonValue` 索引签名;两者结构等价,所以投影值仍读回为 `SearchResultView`。
|
||||
|
||||
TUI(`packages/ui/tui/src/components/transcript.ts`)不需要专门分支:它的结果视图 switch 显式处理 `terminal` 与 `diff`,`search` 视图落入同一个变暗的 generic body,从 `this.result?.content` 读取面向模型的文本。因为搜索视图不带自己的 `content`,而本 PR 之前 grep/glob 返回的是 generic 卡片,所以 TUI 输出与无 search 卡片的回退逐字节一致。渲染结构化 `files`/`paths` 形状的 web 前端是另一个后续 PR;本 PR 是后端契约及其两个生产者。
|
||||
没有专用 `search` 分支的消费方会回退到同一个 generic body,并从原始结果中读取面向模型的文本。因为搜索视图不带自己的 `content`,而本 PR 之前 grep/glob 返回的是 generic 卡片,所以该回退与引入 search 卡片之前的路径逐字节一致。渲染结构化 `files`/`paths` 形状的前端独立于这个后端契约及其两个生产者。
|
||||
|
||||
## 考虑过的备选
|
||||
|
||||
@@ -48,7 +48,7 @@ TUI(`packages/ui/tui/src/components/transcript.ts`)不需要专门分支:
|
||||
|
||||
`grep` 与 `glob` 现在在每次非嵌套的成功调用上计算 `presentationMeta`,这是对已保留匹配或路径的一次有界投影 —— 与 render 消费的是同一份保留产出,所以没有第二次保留计算,线上也没有翻倍的搜索文本。序列化 meta 受 `searchMetaMaxBytes` 约束,所以宽泛搜索不再把无界的结构化副本持久化进会话日志。
|
||||
|
||||
无 search 卡片的 UI 渲染原始 `tool/result` 内容,所以没有消费方退化,TUI 也逐字节一致。渲染结构化形状的 web 消费方读 `truncated`/`total` 与按文件分组;因为视图只携带保留的、字节有界的页,想要完整结果的 UI 跟随面向模型文本里的 spill 定位符,与模型的做法完全一致。
|
||||
无 search 卡片的 UI 渲染原始 `tool/result` 内容,所以没有消费方退化。渲染结构化形状的消费方读 `truncated`/`total` 与按文件分组;因为视图只携带保留的、字节有界的页,想要完整结果的 UI 跟随面向模型文本里的 spill 定位符,与模型的做法完全一致。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card.md
|
||||
2026-07-30-web-read-card.md: 1fb3d61a113d26f6daf023fc791f3638055b5be0
|
||||
2026-07-30-web-read-card.zh.md: 946bcca95bcc9bb50beb1ef22e77e4a21728b538
|
||||
2026-07-30-web-read-card.md: 26d1634b6be86666980e65f842de51c868c26efd
|
||||
2026-07-30-web-read-card.zh.md: 749177e93e8e3b2e34aed46d1fe99226395a6686
|
||||
|
||||
@@ -16,7 +16,7 @@ Add a fourth `card` tag, `read`, to the [render-intent union](../architecture/20
|
||||
|
||||
The read tool projects the structured window through `output.presentationMeta`, the same persisted channel write/edit use for their applied-diff hunks ([canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md)). `presentationMeta` runs once for a top-level surface call, returns `{ path, offset, lines, totalLines, lang? }` as JSON the session validates and stores on the result's `meta`, and `presentResult` narrows that meta back into the `ReadResultView` on both live and replay paths. `offset` (the 1-based first line the window requested) rides along because a byte cap below the first selected line yields an empty `lines` array with a positive `totalLines`; without the persisted `offset` a replayed card of such a window could not report where it starts or where a continuation resumes, and the last-line and re-parse fallbacks are both lossy. Without this channel the line array and total would be unreachable: the raw output object is not on the wire, and re-parsing the `N: text` text is lossy and fragile against the truncation footer.
|
||||
|
||||
`presentResult` returns `undefined` — the generic fallback — whenever the meta is absent or malformed (`readMetaFromMeta` narrows it defensively, so a replay of an older logged result never throws), whenever the result is an error, and whenever the single text block is not the read envelope. A pre-card logged result — a valid read envelope with no persisted `meta`, recorded before this card existed — takes that same `undefined` path deliberately: the client falls back to the raw `result.content`, so it shows the enveloped `<path>/<type>/<content>` text rather than the envelope-stripped generic card the old presenter returned. This is the accepted degradation under the [pre-release stance](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius): reject the old on-disk format rather than add an envelope-stripping compatibility branch, since this PR re-records every published fixture and the session format promises no backward compatibility. On the success path `presentResult` carries `content` (the envelope-stripped text) alongside the structured fields, so a UI without the read capability, including the current TUI, renders the file text through the generic/default card arm exactly as before. The TUI's `renderBody` switch (`packages/ui/tui/src/components/transcript.ts`) is not `assertNever`-exhaustive: `terminal` and `diff` have arms and everything else falls through to the generic arm, which reads `view.content`. That default arm alone was not enough: `render()` sets `genericContent` — and with it the dim-Markdown `dimBody` treatment — on a separate gate that was `card === 'generic'` only, so a `read` card would have kept the text but lost its dim styling. The gate now admits `card: 'read'` too, taking `content` down the same dim-Markdown path, so a read renders in the TUI exactly as it did before the read card existed. Beyond that one gate the TUI needs no read-specific code.
|
||||
`presentResult` returns `undefined` — the generic fallback — whenever the meta is absent or malformed (`readMetaFromMeta` narrows it defensively, so a replay of an older logged result never throws), whenever the result is an error, and whenever the single text block is not the read envelope. A pre-card logged result — a valid read envelope with no persisted `meta`, recorded before this card existed — takes that same `undefined` path deliberately: the client falls back to the raw `result.content`, so it shows the enveloped `<path>/<type>/<content>` text rather than the envelope-stripped generic card the old presenter returned. This is the accepted degradation under the [pre-release stance](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius): reject the old on-disk format rather than add an envelope-stripping compatibility branch, since this PR re-records every published fixture and the session format promises no backward compatibility. On the success path `presentResult` carries `content` (the envelope-stripped text) alongside the structured fields, so a UI without the read capability renders the file text through its generic/default card arm. The former TUI established the need for this fallback: its non-exhaustive result switch read `view.content`, while a separate dim-Markdown gate also had to admit `card: 'read'`. That frontend has since been removed, but the content fallback remains part of the view contract for any consumer without a structured read card.
|
||||
|
||||
### Language hint derivation
|
||||
|
||||
@@ -34,13 +34,13 @@ The read tool projects the structured window through `output.presentationMeta`,
|
||||
|
||||
## Consequences
|
||||
|
||||
`ToolResultView` has a fourth member. Every consumer that switches on `card` keeps compiling: the TUI and the current Web client route an unknown card to their generic path, and the read card carries `content` so that path shows the file text. The Web frontend that renders the line-numbered, syntax-highlighted view from `lines`/`lang`/`totalLines` is a separate follow-up PR; this PR is the backend that makes the data reachable. Until that lands, a read renders exactly as it did before (the generic text card) everywhere.
|
||||
`ToolResultView` has a fourth member. A consumer may render the structured `lines`/`lang`/`totalLines` shape or route an unsupported card to its generic path; the read card carries `content` so the latter still shows the file text. This producer change is the backend that makes the structured data reachable without requiring every consumer to implement the richer view at once.
|
||||
|
||||
The read tool now computes `presentationMeta` for every top-level read, a small per-call projection (a `lines.map` and one `langFromPath` call) on data already in hand. The meta is persisted with the session log, so a read result is slightly larger on disk — the line array it already rendered as text, now also structured.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/fs/tool-fs/tests/read-render.spec.ts` unit-tests `langFromPath` (known extensions case-insensitively, extension read after the last segment and last dot, and the `undefined` cases: dotfile, extensionless, trailing dot, unknown) and `readMetaFromMeta` (a well-formed narrow with and without `lang`, and every rejection: non-object, array, missing or wrong-typed `path`/`totalLines`/`lines`, a malformed line entry, a non-string `lang`, and — because the function narrows the opaque persisted `meta` boundary — the semantically invalid paths a well-typed replayed JSON can still carry: an `offset` that is not a 1-based integer, a first line `number` below `offset`, a line `number` that is not a 1-based integer (`0`, `1.5`, `NaN`, `Infinity`), a `totalLines` that is not a non-negative integer (`-1`, `1.5`, `NaN`), and lines whose numbers duplicate, decrease, or exceed `totalLines`; it also narrows an empty window at a positive `offset` (a byte cap below the first selected line). `packages/fs/tool-fs/tests/tools.spec.ts` pins the tool wiring: `execute` attaches the structured window (with and without a `lang` hint) as `meta`, `presentResult` narrows it into a `card: 'read'` view carrying the envelope-stripped `content`, and the decline paths (error result, non-single-text content, malformed envelope with valid meta, and valid envelope with absent or malformed meta) all fall back to `undefined`. Both changed source files hold per-file 100% coverage. This PR carries the snapshot evidence for the persisted meta and the extended union, not for a new rendered view: the re-recorded ACP session fixtures (`fs-read`, `fs-read-window`, `fs-edit`, `fs-policy-reject`, `fs-write-overwrite`, `parallel-tool-calls`, `workspace-context`, `workspace-edit`) pin the persisted read `meta` (with `{{cwd}}`-tokenized paths), and `cordis-inspect-jsdoc` pins the four-member `ToolResultView` union. The keyless snapshot and assembled-application transcript for the rendered read card belong to the follow-up Web PR that consumes the view, since this PR adds no new product-user-visible rendering — the TUI routes the read card through its existing generic dim-Markdown fallback (`transcript.ts` treats `card: 'read'` like `card: 'generic'`), so its output is unchanged. The `apps/cli` `parallel-file-reads` terminal golden (`apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt`) pins exactly that: a real replay executes the read tool, renders it through the new `card: 'read'` gate, and the golden's dim-Markdown rows are byte-for-byte what a generic read produced before this card existed.
|
||||
`packages/fs/tool-fs/tests/read-render.spec.ts` unit-tests `langFromPath` (known extensions case-insensitively, extension read after the last segment and last dot, and the `undefined` cases: dotfile, extensionless, trailing dot, unknown) and `readMetaFromMeta` (a well-formed narrow with and without `lang`, and every rejection: non-object, array, missing or wrong-typed `path`/`totalLines`/`lines`, a malformed line entry, a non-string `lang`, and — because the function narrows the opaque persisted `meta` boundary — the semantically invalid paths a well-typed replayed JSON can still carry: an `offset` that is not a 1-based integer, a first line `number` below `offset`, a line `number` that is not a 1-based integer (`0`, `1.5`, `NaN`, `Infinity`), a `totalLines` that is not a non-negative integer (`-1`, `1.5`, `NaN`), and lines whose numbers duplicate, decrease, or exceed `totalLines`; it also narrows an empty window at a positive `offset` (a byte cap below the first selected line). `packages/fs/tool-fs/tests/tools.spec.ts` pins the tool wiring: `execute` attaches the structured window (with and without a `lang` hint) as `meta`, `presentResult` narrows it into a `card: 'read'` view carrying the envelope-stripped `content`, and the decline paths (error result, non-single-text content, malformed envelope with valid meta, and valid envelope with absent or malformed meta) all fall back to `undefined`. Both changed source files hold per-file 100% coverage. This PR carries the snapshot evidence for the persisted meta and the extended union, not for a new rendered view: the re-recorded ACP session fixtures (`fs-read`, `fs-read-window`, `fs-edit`, `fs-policy-reject`, `fs-write-overwrite`, `parallel-tool-calls`, `workspace-context`, `workspace-edit`) pin the persisted read `meta` (with `{{cwd}}`-tokenized paths), and `cordis-inspect-jsdoc` pins the four-member `ToolResultView` union. The then-current terminal snapshot also pinned that a consumer's generic dim-Markdown fallback stayed byte-identical; the structured card's own assembled-application transcript belonged to its consuming frontend change.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
read 工具通过 `output.presentationMeta` 投影结构化窗口,这与 write/edit 用来投影其应用 diff hunk 的持久化通道相同([规范化工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。`presentationMeta` 对一次顶层 surface 调用运行一次,返回 `{ path, offset, lines, totalLines, lang? }` 作为会话校验并存储在结果 `meta` 上的 JSON,`presentResult` 在 live 和回放路径上都把该 meta 收窄回 `ReadResultView`。`offset`(窗口请求的 1-based 起始行)一并携带,是因为当字节上限低于首个选中行时,窗口会返回空的 `lines` 数组而 `totalLines` 为正;没有持久化的 `offset`,这类窗口的回放 card 就无法报告它从哪行开始、或续读应从哪行继续,而末行推断与文本重解析两种兜底都有损。没有这个通道,行数组和总数就无法触及:原始输出对象不在线上,而重新解析 `N: text` 文本既有损又对截断脚注脆弱。
|
||||
|
||||
`presentResult` 在以下情况返回 `undefined`——即 generic 回退:meta 缺失或畸形(`readMetaFromMeta` 防御性收窄它,因此回放旧的已记录结果永不抛错)、结果是错误、以及单个文本块不是 read 信封。本 card 出现之前记录的结果——信封合法但无持久化 `meta`——有意走同一条 `undefined` 路径:客户端回退到原始 `result.content`,因此显示带 `<path>/<type>/<content>` 信封的原文,而非旧展示器返回的剥信封 generic card。这是 [pre-release 立场](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius)下接受的降级:拒绝旧的磁盘格式,而非加一个剥信封的兼容分支——本 PR 已重录全部已发布 fixtures,且 session 格式不承诺向后兼容。在成功路径上,`presentResult` 在结构化字段之外携带 `content`(剥信封后的文本),因此不具备 read 能力的 UI(包括当前的 TUI)通过 generic/default card 分支渲染文件文本,与之前完全一致。TUI 的 `renderBody` switch(`packages/ui/tui/src/components/transcript.ts`)不是 `assertNever` 穷尽的:`terminal` 和 `diff` 有分支,其余都落入 generic 分支,该分支读取 `view.content`。仅有该默认分支还不够:`render()` 在一个独立门控上设置 `genericContent`(连同 dim-Markdown 的 `dimBody` 处理),该门控原先只判 `card === 'generic'`,因此 `read` card 虽保留文本却会丢失 dim 样式。现在该门控也接纳 `card: 'read'`,让 `content` 走同一条 dim-Markdown 路径,因此 read 在 TUI 中的渲染与 read card 出现之前完全一致。除这一处门控外,TUI 无需 read 专属代码。
|
||||
`presentResult` 在以下情况返回 `undefined`——即 generic 回退:meta 缺失或畸形(`readMetaFromMeta` 防御性收窄它,因此回放旧的已记录结果永不抛错)、结果是错误、以及单个文本块不是 read 信封。本 card 出现之前记录的结果——信封合法但无持久化 `meta`——有意走同一条 `undefined` 路径:客户端回退到原始 `result.content`,因此显示带 `<path>/<type>/<content>` 信封的原文,而非旧展示器返回的剥信封 generic card。这是 [pre-release 立场](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius)下接受的降级:拒绝旧的磁盘格式,而非加一个剥信封的兼容分支——本 PR 已重录全部已发布 fixtures,且 session 格式不承诺向后兼容。在成功路径上,`presentResult` 在结构化字段之外携带 `content`(剥信封后的文本),因此不具备 read 能力的 UI 会通过自己的 generic/default card 分支渲染文件文本。原 TUI 证明了这条回退的必要性:它的非穷尽结果 switch 读取 `view.content`,而另一道 dim-Markdown 门控也必须接纳 `card: 'read'`。该前端随后被移除,但对任何没有结构化 read 卡片的消费方而言,content 回退仍是视图契约的一部分。
|
||||
|
||||
### 语言提示推导
|
||||
|
||||
@@ -34,13 +34,13 @@ read 工具通过 `output.presentationMeta` 投影结构化窗口,这与 write
|
||||
|
||||
## Consequences
|
||||
|
||||
`ToolResultView` 多了第四个成员。每个在 `card` 上 switch 的消费者都继续编译:TUI 和当前 Web 客户端把未知 card 路由到其 generic 路径,而 read card 携带 `content` 使该路径显示文件文本。从 `lines`/`lang`/`totalLines` 渲染带行号、语法高亮视图的 Web 前端是单独的后续 PR;本 PR 是让数据可触及的后端。在它落地前,read 在各处的渲染与之前完全一致(generic 文本 card)。
|
||||
`ToolResultView` 多了第四个成员。消费方可以渲染结构化的 `lines`/`lang`/`totalLines` 形状,也可以将不支持的 card 路由到 generic 路径;read card 携带 `content`,所以后者仍会显示文件文本。本次生产者变更是让结构化数据可触及的后端,无需每个消费方同时实现更丰富的视图。
|
||||
|
||||
read 工具现在为每次顶层 read 计算 `presentationMeta`,这是对已在手数据的一次小投影(一次 `lines.map` 和一次 `langFromPath` 调用)。meta 随会话日志持久化,因此 read 结果在磁盘上略大——它已渲染为文本的行数组,现在也以结构化形式存在。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况:dotfile、无扩展名、结尾的点、未知)与 `readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、非字符串 `lang`,以及——因为该函数收窄持久化的 opaque `meta` 边界——良构类型的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的 `offset`、小于 `offset` 的首行 `number`、不是 1-based 整数的行 `number`(`0`、`1.5`、`NaN`、`Infinity`)、不是非负整数的 `totalLines`(`-1`、`1.5`、`NaN`)、以及行号重复、递减或超过 `totalLines` 的情况;并且收窄正 `offset` 处的空窗口(字节上限低于首个选中行))。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content` 的 `card: 'read'` 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。本 PR 携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP session fixtures(`fs-read`、`fs-read-window`、`fs-edit`、`fs-policy-reject`、`fs-write-overwrite`、`parallel-tool-calls`、`workspace-context`、`workspace-edit`)钉住持久化的读取 `meta`(含 `{{cwd}}` 令牌化路径),`cordis-inspect-jsdoc` 钉住四成员的 `ToolResultView` 联合类型。已渲染读取 card 的 keyless 快照与组装应用 transcript 属于消费该视图的后续 Web PR,因为本 PR 不新增任何面向产品用户可见的渲染——TUI 通过其现有的通用 dim-Markdown 回退路由读取 card(`transcript.ts` 把 `card: 'read'` 当作 `card: 'generic'` 处理),因此其输出保持不变。`apps/cli` 的 `parallel-file-reads` 终端 golden(`apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt`)正钉住这一点:一次真实回放执行 read 工具、经新的 `card: 'read'` 门渲染,golden 的 dim-Markdown 行与本 card 出现前 generic read 所产出的逐字节一致。
|
||||
`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况:dotfile、无扩展名、结尾的点、未知)与 `readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、非字符串 `lang`,以及——因为该函数收窄持久化的 opaque `meta` 边界——良构类型的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的 `offset`、小于 `offset` 的首行 `number`、不是 1-based 整数的行 `number`(`0`、`1.5`、`NaN`、`Infinity`)、不是非负整数的 `totalLines`(`-1`、`1.5`、`NaN`)、以及行号重复、递减或超过 `totalLines` 的情况;并且收窄正 `offset` 处的空窗口(字节上限低于首个选中行))。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content` 的 `card: 'read'` 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。本 PR 携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP session fixtures(`fs-read`、`fs-read-window`、`fs-edit`、`fs-policy-reject`、`fs-write-overwrite`、`parallel-tool-calls`、`workspace-context`、`workspace-edit`)钉住持久化的读取 `meta`(含 `{{cwd}}` 令牌化路径),`cordis-inspect-jsdoc` 钉住四成员的 `ToolResultView` 联合类型。当时的终端快照还钉住了消费方的 generic dim-Markdown 回退保持逐字节一致;结构化卡片自身的组装应用 transcript 则属于消费它的前端变更。
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card.md
|
||||
2026-07-30-web-result-card.md: deec27832aba2d5d868889f7306cbaef4f0b90b4
|
||||
2026-07-30-web-result-card.zh.md: 037e029332fbb665d90860d7e11c2fd117e6eb45
|
||||
2026-07-30-web-result-card.md: 838b13a7f3240c753e5cd1af6909389055c6352d
|
||||
2026-07-30-web-result-card.zh.md: 6e5170fcad82d709b050fb05e8efcfe955f20391
|
||||
|
||||
@@ -16,13 +16,13 @@ One tag with a `kind` discriminant, not two tags. Both calls are web retrieval a
|
||||
|
||||
`presentationMeta` carries what render text cannot. The structured result object a tool returns from `execute` does NOT reach a client over the wire — only the model-facing `render` text and, when declared, the `output.presentationMeta` JSON projected onto the `tool/result` event's `meta` do. For `web_search` the meta is the ONLY faithful route to `{url, title?, snippet?, publishedAt?}`: the render collapses those fields into one lossy free-text line, so a consumer cannot reparse them. For `web_fetch` the meta is a smaller but real gain: `url`/`statusCode` are recoverable from the deterministic `Fetched <url> (HTTP <n>)` header line, but `truncated` is the effective truncation — provider cap, pre-conversion source cut, or the deployment's `fetchMaxOutputChars` output cap — which a client cannot recompute because it does not know that cap. The fetch card and the model-facing text derive `truncated` from one shared `renderFetchOutput(result, maxOutputChars)` helper, so the card never disagrees with the footer the model saw. This mirrors the write/edit diff template (`packages/fs/tool-fs/src/diff.ts`): a `*MetaFromValue` projector feeds `output.presentationMeta`, and a `*MetaFromResult` narrower reads `result.meta` back with a defensive fallback to the generic card. `web_fetch`'s body is already markdown in the result content, so it is not duplicated into meta.
|
||||
|
||||
Neither result view carries a `content` copy. A UI that does not render the structured `web` card falls back to the raw `tool/result` content. The TUI does exactly this: it renders no structured web body, and its transcript renderer routes a `web` view's fallback content through the same dim Markdown path as a generic card's content (`packages/ui/tui/src/components/transcript.ts`, where both `render` and `renderBody` narrow the `generic` arm to `view.content` and give a `web` view the same `this.result?.content` fallback). Copying the result content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it and the fallback path renders the identical text. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time.
|
||||
Neither result view carries a `content` copy. A UI that does not render the structured `web` card falls back to the raw `tool/result` content, the same input a generic card consumes. Copying that content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it and the fallback path renders the identical text. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time.
|
||||
|
||||
`presentResult` returns `undefined` (the generic card) on an error result and on absent or malformed `meta`, because presentation runs on replay of arbitrary logged results (possibly from an older schema) and must never throw. The narrowers validate every field defensively; an empty source list is valid meta, not malformed.
|
||||
|
||||
## Consequences
|
||||
|
||||
The web frontend consumer is a separate later PR: this PR adds the contract arm and makes the two tools emit it, with no client-side rendering. The one observable change is that the `web_search`/`web_fetch` `tool/result` events now persist a `data.meta` payload (the `web-fetch` keyless snapshot is refreshed accordingly); the model-facing render text and the TUI presentation are unchanged (the TUI falls back to the same result content). The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer PR that renders it, delivered there. Any existing `ToolResultView` consumer that switches exhaustively must add a `web` arm; the TUI does not switch exhaustively and needs none. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change.
|
||||
The frontend consumer was a separate later PR: this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change.
|
||||
|
||||
A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag.
|
||||
|
||||
|
||||
@@ -16,13 +16,13 @@ Status: implemented
|
||||
|
||||
`presentationMeta` 携带 render 文本无法携带的东西。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会。对 `web_search`,meta 是得到 `{url, title?, snippet?, publishedAt?}` 的**唯一**忠实途径:render 把这些字段压进一行有损的自由文本,消费者无法重新解析。对 `web_fetch`,meta 是更小但真实的收益:`url`/`statusCode` 可从确定格式的 `Fetched <url> (HTTP <n>)` header 行还原,但 `truncated` 是有效截断——provider cap、转换前源截断,或部署的 `fetchMaxOutputChars` 输出上限——客户端无法重算,因为它不知道那个上限。抓取卡片与面向模型的文本都从同一个 `renderFetchOutput(result, maxOutputChars)` helper 派生 `truncated`,因此卡片绝不会与模型看到的脚注分叉。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的正文已是结果内容中的 markdown,因此不重复写入 meta。
|
||||
|
||||
两个结果视图都不携带 `content` 副本。不渲染结构化 `web` 卡片的 UI 回退到原始 `tool/result` 内容。TUI 正是如此:它不渲染结构化的 web 正文,其 transcript 渲染器把 `web` 视图的回退内容与 generic 卡片的内容路由进同一条 dim Markdown 路径(`packages/ui/tui/src/components/transcript.ts` 中 `render` 与 `renderBody` 都把 `generic` 分支收窄为 `view.content`,并给 `web` 视图相同的 `this.result?.content` 回退)。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title`(`args.query`/`args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。
|
||||
两个结果视图都不携带 `content` 副本。不渲染结构化 `web` 卡片的 UI 回退到原始 `tool/result` 内容,这也是 generic 卡片消费的输入。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title`(`args.query`/`args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。
|
||||
|
||||
`presentResult` 在错误结果、以及 `meta` 缺失或畸形时返回 `undefined`(即 generic 卡片),因为 presentation 会在对任意已记录结果(可能来自旧 schema)的重放中运行,绝不能抛错。收窄器防御性地校验每个字段;空来源列表是有效 meta,而非畸形。
|
||||
|
||||
## Consequences
|
||||
|
||||
web 前端消费者是一个独立的后续 PR:本 PR 新增契约分支并让两个工具发出它,不含客户端渲染。唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件现在持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照随之刷新);面向模型的 render 文本与 TUI 呈现不变(TUI 回退到相同的结果内容)。渲染 `web` 卡片的组装应用 transcript 快照属于渲染它的消费者 PR,在那里交付。任何做穷尽 switch 的现有 `ToolResultView` 消费者都必须新增一个 `web` 分支;TUI 并不穷尽 switch,无需新增。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。
|
||||
前端消费方由后续独立 PR 交付:本次生产者变更新增契约分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript 快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费方都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。
|
||||
|
||||
未来想用此卡片的 web 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
|
||||
2026-07-31-web-telemetry-default-mount.md: e9ec7d0cda37db44e753c9aee572763b7e24ada6
|
||||
2026-07-31-web-telemetry-default-mount.zh.md: 68b411d0668772ce81d7f323c2d286714a223ca4
|
||||
2026-07-31-web-telemetry-default-mount.md: c1525a44196991d5059969ea70af34501b199323
|
||||
2026-07-31-web-telemetry-default-mount.zh.md: f203ea1943387beda445bd81ac78fa2cc0471d45
|
||||
|
||||
@@ -10,15 +10,15 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry
|
||||
|
||||
## Decision
|
||||
|
||||
The shared `dsh` core (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so every surface — TUI, web, and headless — reports; this is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Each surface's exit path drains the queue: web/headless dispose on SIGINT/SIGTERM (headless gained those handlers in this change), and the TUI's normal exit runs `disposeRootAndExit` (root dispose, 5s bounded — above the ~1s drain ceiling configured here) while its `/resume` handoff disposes the root before `execve`.
|
||||
The shared `dsh` base (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so Web and headless report; the raw-config command also mounts it before applying its required deployment overlay. This is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving the backend's three-second shutdown deadline time to drain before the five-second launcher bound.
|
||||
|
||||
| Ruling | Value | Rationale |
|
||||
|---|---|---|
|
||||
| Mount surface | base.cordis.yml (TUI + web + headless) | One deployment stance for every surface; per-surface divergence would need a reason, and none exists |
|
||||
| Mount surface | base.cordis.yml (raw config + Web + headless) | One deployment stance for every tree that loads the shared base; the raw overlay decides whether that deployment creates sessions |
|
||||
| Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs |
|
||||
| Opt-out switch | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables | A privacy switch prefers off-by-mistake over on-by-mistake; a row can only be disabled at AppCLIEntry's patch layer (config has no disable semantic, and the switch must precede the load-time `exporter.url` validation) |
|
||||
| Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval |
|
||||
| Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` | Dispose must release within ~1s against an unreachable collector: timeoutMillis doubles as the per-attempt socket timeout and the retry deadline (1s effectively disables the SDK's 5-try backoff), and aligning batch size with the queue cap makes the drain a single batch; SDK defaults can stall 40s+ |
|
||||
| Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | Ordinary unreachable-collector failure releases in ~1s: timeoutMillis is the per-attempt socket timeout and retry deadline, while one queue-sized batch avoids sequential drain multiplication. The DSH-owned 3s outer bound covers the SDK's preceding unbounded `forceFlush()` wait when the transport Promise never obtains a socket. |
|
||||
| Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth |
|
||||
| CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in all 8 GitHub workflows | Every CI channel that boots the web composition (e2e/snapshot/built smokes) must not stream test sessions to the production endpoint |
|
||||
|
||||
@@ -30,7 +30,7 @@ The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the depl
|
||||
|
||||
**A config field instead of an env patch for the switch.** Infeasible: cordis rows have no config-level disable semantic, and `exporter.url` validation fails loud at plugin construction, so the switch must take effect before the Loader — AppCLIEntry's patch layer is the only seat.
|
||||
|
||||
**A `Promise.race` timeout backstop around exit.** Deferred: the parameter set already bounds the worst-case drain to ~1.5-3s (typically <100ms), measured SIGINT-to-exit 110ms-1.1s; the unbounded drip-feed-response risk stays under observation, and on real evidence the race lands inside the backend's `shutdown()` (never the coordinator — that would decide loss semantics for every backend).
|
||||
**A `Promise.race` timeout backstop around exit.** Originally deferred because the SDK parameters appeared to bound the backend's drain to ~1.5-3s (typically <100ms), with measured SIGINT-to-exit of 110ms-1.1s. A Linux sandbox reproduction later proved that `BatchLogRecordProcessor.shutdown()` can wait forever in `exporter.forceFlush()` before reaching its `exportTimeoutMillis`-bounded completion Promise. The [CLI shutdown fix](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) therefore adds both a three-second backend bound for that specific gap and a five-second process-level bound plus repeated-signal escape for the whole plugin tree.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -10,15 +10,15 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh` 共享核心(`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint,因此所有 surface——TUI、web、headless——都上报;这是**内部测试期的部署立场**——有 endpoint 就报,用户可经环境变量退出。各 surface 的退出路径都会排空队列:web/headless 在 SIGINT/SIGTERM 上 dispose(headless 的信号处理是本次补上的),TUI 的正常退出走 `disposeRootAndExit`(根 dispose,5s 兜底——高于此处配置的 ~1s drain 上界),其 `/resume` 移交也在 `execve` 前 dispose 根。
|
||||
`dsh` 共享 base(`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint,因此 Web 与 headless 都会上报;原始配置命令也会先挂载该行,再应用其必需的部署 overlay。这是**内部测试期的部署立场**——有 endpoint 就上报,用户可通过环境变量退出。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md),在启动器 5 秒上限到期前,先给后端 3 秒关闭截止时间完成排空。
|
||||
|
||||
| 决策项 | 取值 | 理由 |
|
||||
|---|---|---|
|
||||
| 挂载面 | base.cordis.yml(TUI + web + headless) | 所有 surface 一个部署立场;按 surface 分化需要理由,而当前没有 |
|
||||
| 挂载面 | base.cordis.yml(原始配置 + Web + headless) | 所有加载共享 base 的配置树采用同一个部署立场;原始配置 overlay 决定该部署是否创建会话 |
|
||||
| endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 |
|
||||
| 退出开关 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即关 | 隐私向开关取「宁关勿误开」;行级 disable 只能在 AppCLIEntry 的 patch 层做(config 无 disable 语义,且必须先于 `exporter.url` 的加载期校验生效) |
|
||||
| 上报节奏 | `processor.scheduledDelayMillis: 10000`(10s/批) | 流式回流,非退出才报;崩溃至多丢最后一个未导出间隔 |
|
||||
| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048(== maxQueueSize)` + `exportTimeoutMillis: 1500` | collector 不可达时 dispose 必须 ~1s 内放行:timeoutMillis 同时是单次 socket 超时与重试 deadline(1s 等效关掉 SDK 5 次 backoff),批大小对齐队列上限使 drain 恒为单批;默认参数下最坏可卡 40s+ |
|
||||
| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048(== maxQueueSize)` + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | collector 不可达的常规故障会在约 1s 内放行:timeoutMillis 是单次 socket 超时与重试 deadline,使用与队列等大的单批可避免依次排空导致耗时倍增。由 DSH 管理的 3s 外层上限覆盖 SDK 先执行的无界 `forceFlush()` 等待,即传输 Promise 始终无法取得 socket 的情况。 |
|
||||
| 压缩 | `compression: gzip` | 事件 body 含全文,跨机房带宽 |
|
||||
| CI 隔离 | 全部 8 个 GitHub workflow 顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | CI 启动 web 组合的所有通道(e2e/snapshot/built smoke)不得向生产 endpoint 泄测试会话 |
|
||||
|
||||
@@ -30,7 +30,7 @@ Status: implemented
|
||||
|
||||
**开关做成 config 字段而非 env patch。** 不可行:cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud,开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。
|
||||
|
||||
**退出时 `Promise.race` 兜底超时。** 暂缓:参数组合已把最坏 drain 压到 ~1.5-3s(典型 <100ms),实测 SIGINT→退出 110ms-1.1s;drip-feed 慢滴响应的无界等待风险留观,出现实证再在 backend `shutdown()` 内加 race(不放 coordinator——那会替所有 backend 决定丢失语义)。
|
||||
**退出时 `Promise.race` 兜底超时。** 最初暂缓,是因为 SDK 参数看似已经将后端排空耗时限制在约 1.5-3s(通常 <100ms),实测 SIGINT 到退出耗时 110ms-1.1s。后来在 Linux 沙箱中复现并证明,`BatchLogRecordProcessor.shutdown()` 可能在 `exporter.forceFlush()` 中永久等待,无法进入受 `exportTimeoutMillis` 限制的完成 Promise。因此,[CLI 关闭修复](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) 既为这一特定缺口增加 3 秒后端上限,也为整棵插件树增加 5 秒进程级上限和重复信号退出途径。
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -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-08-03-web-turn-run-time.md
|
||||
2026-08-03-web-turn-run-time.md: b6e79b34f45ebe46d9ce752b6333cdfce6fc4dd6
|
||||
2026-08-03-web-turn-run-time.zh.md: 73d9be4d2119278f8a78c6858bae353a4ef5d62f
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Web turn run time and hover-revealed time chrome
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-03-web-turn-run-time.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web chat shows when a message arrived but not how long the agent worked on it. Long turns give no live progress signal beyond the static activity label, and after the turn settles the wall time is not recoverable from the UI. Meanwhile the always-visible clock row adds visual noise to every message.
|
||||
|
||||
## Decision
|
||||
|
||||
Turn wall time uses the existing logged `turn/start` and `turn/end` timestamps, with no new session events. The client Session folds each in-window pair into `turnTimings`; the actions-owning assistant footer renders `endTime - startTime` as a localized `Ran for {duration}` label after the turn ends. The running `TurnStatus` clock uses the latest timing without an end, so reload preserves elapsed time, steering does not reset it, and a retry starts from its own logged boundary. Both readings use the same localized formatter and whole-second floor. The clock appears only after 15 seconds and is hidden from the live region so screen readers announce the activity status without replaying every tick.
|
||||
|
||||
Time chrome (clock and run time) is hover-revealed: message containers opt in with a `data-time-hover-root` attribute, and `MessageIconActions.module.css` fades the time label in on container `:hover`/`:focus-within`. The rule is scoped to `@media (hover: hover)`, so touch devices keep the always-visible label; opacity (not display) keeps the layout stable. Copy/branch icons stay always visible.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Deriving timing from message nodes.** The nearest user or steering timestamp is available in the rendered transcript, but it mismeasures retry turns and lets mid-turn steering reset the live clock. Existing turn boundary events provide the authoritative timestamps without changing the log format.
|
||||
|
||||
**Anchoring the live clock to component mount.** Simpler, but a mid-turn reload would restart the clock at zero and disagree with the eventual footer label. Mount time remains only the fallback when `turn/start` is outside the loaded window.
|
||||
|
||||
**Hiding the whole actions row until hover.** Copy and branch are affordances worth discovering, and row-level show/hide risks layout shift. Only the passive time text is hover-gated.
|
||||
|
||||
## Consequences
|
||||
|
||||
Turn duration is visible live and after settlement without new session events, and both readings share exact log boundaries and formatting. The settled duration includes activity after the last assistant text up to `turn/end`; the label is absent when `turn/start` is outside the loaded window. Time chrome no longer competes with message content at rest, and the ticking clock remains visual rather than repeatedly announced.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Web 轮次运行时长与悬停显示的时间附属元素
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-03-web-turn-run-time.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 聊天界面会显示消息的到达时间,却不显示 agent(智能体)处理这条消息花了多久。长轮次除静态活动标签外没有任何实时进度信号,轮次结束后也无法从 UI 中还原实际耗时。与此同时,始终可见的时钟行给每条消息都增加了视觉噪音。
|
||||
|
||||
## 决策
|
||||
|
||||
轮次实际耗时(wall time)采用日志中已有的 `turn/start` 和 `turn/end` 时间戳,不新增任何会话事件。客户端 Session 将加载窗口内的每对边界归并到 `turnTimings` 中;轮次结束后,承载操作图标的 assistant 页脚把 `endTime - startTime` 渲染为本地化的 `Ran for {duration}` 标签。运行中的 `TurnStatus` 时钟采用最新一条没有结束时间的计时记录,因此重新加载会保留已用时长,steering(中途引导)不会重置计时,重试也从自身的日志边界开始。两处读数共用同一个本地化格式化器,并向下取整到整秒。该时钟在 15 秒后才出现,并从实时区域中隐藏,因此屏幕阅读器会播报活动状态而不会重复播报每次时钟跳动。
|
||||
|
||||
时钟与运行时长这类时间附属元素(time chrome)在悬停时才显示:消息容器通过 `data-time-hover-root` 属性显式启用该行为,`MessageIconActions.module.css` 在容器处于 `:hover`/`:focus-within` 时以淡入方式显示时间标签。该规则限定在 `@media (hover: hover)` 之内,触屏设备因此保持标签始终可见;显隐通过 opacity(而非 display)实现,布局保持稳定。复制与分支图标始终可见。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**从消息节点推导计时。** 渲染后的 transcript(文本记录)中可以取得最近的用户或 steering 时间戳,但这会错误计算重试轮次,并让轮次中途的 steering 重置实时时钟。已有的轮次边界事件无需改变日志格式即可提供权威时间戳。
|
||||
|
||||
**将实时时钟锚定到组件挂载时刻。** 更简单,但轮次进行中重新加载会让时钟从零重新计时,并与最终的页脚标签不一致。仅当 `turn/start` 位于已加载窗口之外时,才回退到挂载时刻。
|
||||
|
||||
**将整个操作行隐藏至悬停时才显示。** 复制与分支是值得让用户发现的操作入口,而整行级别的显隐切换有布局偏移的风险。只有被动的时间文本由悬停控制显隐。
|
||||
|
||||
## 后果
|
||||
|
||||
轮次时长在运行中和结束后都可见,且不需要新的会话事件;两处读数共用精确的日志边界和格式化方式。结束后的时长包括最后一条 assistant 文本之后、直至 `turn/end` 的活动;若 `turn/start` 位于已加载窗口之外,则不显示标签。未交互时,时间附属元素不再与消息内容争夺注意力,持续跳动的时钟也只保留视觉呈现,不会被重复播报。
|
||||
@@ -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-08-04-pointer-revealed-sidebar-scrollbars.md
|
||||
2026-08-04-pointer-revealed-sidebar-scrollbars.md: eb86f710e44cd8f1fb2cef41db99504af42413bb
|
||||
2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md: 598c10dc2373eeb821d05f349a662156b194c71b
|
||||
@@ -0,0 +1,63 @@
|
||||
# Agent Note: The sidebar's scrollbars follow the pointer
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The sidebar's session list overflows after a handful of sessions, and from that point its scrollbar is drawn permanently — in a column that is at rest most of the time, next to rows whose own chrome only appears on hover. It is the one piece of always-on furniture in the sidebar, and nothing about it is actionable until someone reaches for it. The product ask (2026-08-04) is to draw it only while the pointer is over the sidebar, with a short tail so it does not blink out on the way past.
|
||||
|
||||
## Decision
|
||||
|
||||
`SidebarRoot` tracks the pointer over the whole column and carries a `quietBars` class whenever it is outside. The rule that class selects rebinds ui-theme's indirection pair — `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` — to `transparent`, so every scroll region nested under the column draws no thumb. The session list is the only one today; a future one inherits the behavior rather than opting into it.
|
||||
|
||||
The tail is `SCROLLBAR_LINGER_MS = 2000`: leaving arms a timer, entering cancels a pending one, and only the timer firing puts the class back. A pointer that crosses the column's edge and returns — travelling around a portalled menu, or overshooting on the way to a row — never sees the thumb blink.
|
||||
|
||||
Entering is the column's own `pointerenter`; leaving is decided against the column's box, from a `pointermove` listener that exists only while the bars are drawn. DOM containment cannot decide the leave, because ui-settings renders its full-viewport settings panel as a fixed-position *descendant* of this column: a pointer moved onto that panel — or onto the conversation after it closes — never fires `pointerleave` here, and the bars would stay drawn over a column nobody is pointing at. The element's own leave is kept for the one case geometry cannot see, a pointer that leaves the window and emits no further moves.
|
||||
|
||||
The pointer surface is the column, not the list. A pointer heading for the bar crosses the logo row, the New Session capsule, and the search field first, so revealing on the list alone would surface the bar only once the pointer was already among the rows.
|
||||
|
||||
`transparent` is what makes the reveal free of layout. `scrollbar-gutter: stable` on the list exists so rows never move ([the gutter note](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)); rebinding a colour leaves that reservation in force, so the thumb appears in space the list was already holding for it.
|
||||
|
||||
The indirection pair rather than a rule on the list, because that pair is ui-theme's documented rebinding seam: one declaration reaches both rendering paths (the WebKit pseudo-elements and Firefox's `scrollbar-color`), and custom properties inherit, which is what makes the column — rather than each scroll region in it — the thing that owns the state.
|
||||
|
||||
That widens the rebinding contract, so its gate states the new shape rather than accepting it by silence: `ui-theme/tests/scrollbar-styles.spec.ts` admits exactly two rebind targets, the l2 pair or `transparent`, and judges the *rule* rather than each declaration — a mixed rule (`thumb: transparent` beside an l2 hover) would repaint the bar the moment the pointer reached it while passing a per-declaration check. The elevation half compares whole values against the pair's canonical spelling, which is also what rejects a crossed pair and a token wrapped in a literal expression; an l1 rebind and a bare colour were already out.
|
||||
|
||||
Hiding no longer counts as elevating: only an l2 rebind exempts a sheet from "every sheet that scrolls on an elevated surface rebinds". A sheet that hides its bars and also scrolls on an elevated surface still owes the l2 pair for whatever draws a thumb there.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**CSS `:hover` on the column, with no JavaScript state.** The whole mechanism in one rule, and it cannot express the tail: the bar would vanish on the frame the pointer crossed the edge, which is exactly when a pointer is travelling to the conversation or around a portalled menu. The ask names the tail, and a hover-only version reads as flicker.
|
||||
|
||||
**Keep it in CSS and get the delay from a transition,** by registering `--dsh-scrollbar-thumb` through `@property` so the custom property becomes animatable and a `transition-delay` could hold the colour. Rejected on cost and on reach: the registration is global to every surface that reads the pair, for one column's timing, and the WebKit scrollbar pseudo-elements this palette actually renders through do not reliably transition — the delay would be specified where it cannot be observed.
|
||||
|
||||
**Hide the bar itself** — `scrollbar-width: none`, or `display: none` on `::-webkit-scrollbar`. Rejected because it takes the reserved band with it: the bar would reappear by re-taking 8px and shift every row sideways under the pointer that revealed it, which is the regression the gutter reservation was added to fix.
|
||||
|
||||
**Draw an overlay thumb in the app** and hide the native bar entirely, which is what a fully custom fade would need. It buys arbitrary styling and costs hit-testing, drag, wheel, momentum, and both palettes' hover states — a large owned surface for a cosmetic gain, in a client whose scrollbars are already themed through tokens.
|
||||
|
||||
**Scope the reveal to the scrolling list rather than the column.** Fewer elements involved, and it puts the reveal at the wrong boundary: the pointer reaches the rows last, so the bar would appear after the user is already reading them, and every other scroll region added to the sidebar later would have to opt in by hand.
|
||||
|
||||
**Reveal on scroll events too,** so a keyboard- or touch-driven scroll shows the bar. Rejected as drawing an affordance the input that triggered it cannot use; the rows themselves already show that the list moved.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A list scrolled by keyboard or by a touch drag shows no thumb once the linger passes, since neither leaves a pointer over the column. The e2e pins this rather than only describing it.
|
||||
- Dragging the thumb itself out of the column does not hide it mid-drag: the scrollbar takes the pointer capture, so the page receives no `pointermove` while the button is held. Measured in Chromium — the bar stays drawn and keeps scrolling with the pointer 900px to its right, past the linger window.
|
||||
- The column starts quiet on a cold load and stays so until the pointer first moves over it. A pointer already parked there when the page loads fires nothing until it moves, which is the browser's rule rather than this shell's.
|
||||
- An elevated surface nested in the column that rebinds the pair to l2 for its own elevation overrides the quiet state and keeps its bar drawn. Nothing in the sidebar does this today.
|
||||
- The shell's DOM now carries a state class, so ui-sidebar's shell snapshots pin `quietBars` and a regression in the default state is a snapshot diff rather than something someone has to notice in a screenshot.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx` drives the class through the transitions with fake timers: revealed on entry, still revealed one millisecond before the linger closes, quiet one millisecond after, and cancelled by a return within the window. Two more cover the geometric leave: a `pointermove` landing outside the column's box hides the bars without any DOM leave (the settings-panel shape), and one landing back inside cancels a pending hide. It also unmounts mid-linger and asserts no timer survives — a pending hide firing into a dead component is the failure this shape is prone to. The events are `pointerover`/`pointerout` carrying a `relatedTarget`, because React synthesizes enter and leave from those and ignores the raw ones.
|
||||
|
||||
`packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts` reads the sheet: the rule states both halves of the pair — rebinding the resting thumb alone would leave the hover colour painting the moment the pointer reached the bar — and states no `scrollbar-gutter`, which belongs to the scrolling region.
|
||||
|
||||
`apps/web/tests/sidebar-scrollbar.e2e.ts` is where the two halves meet a real engine. It parks the pointer over the list before every colour reading, since a scenario that never moves the mouse would measure the quiet state throughout and read as vacuous green. Its own test then moves the pointer away, asserts the thumb is still drawn on the leave itself, polls until it resolves to `rgba(0, 0, 0, 0)`, re-measures the geometry there to prove the reservation held while the bar was hidden, and scrolls the list programmatically — what a keyboard or a touch drag does — to pin that a pointerless scroll draws nothing. The committed golden records the thumb at both pointer positions in both palettes.
|
||||
|
||||
The e2e's control is a mutation, and it needs the plugin's own bundle: dropping `quietBars` from the shell, rebuilding `@deepseek-ai/dsh-client-ui-sidebar` and only then `build:web`, turns that test red on the thumb resolving to `rgb(229, 229, 229)` where it expects `rgba(0, 0, 0, 0)`. Rerunning `build:web` alone exercises a stale bundle and passes with the change removed, which is the trap [the gutter note](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) documented.
|
||||
|
||||
The widened gate has its own controls, each a one-declaration mutation of a real sheet: crossing `transparent` with an l2 hover, and wrapping an l2 token in `color-mix(…)`, each turn the pair assertion red.
|
||||
|
||||
The recording that demonstrates this behavior has to be headed. Headless Chromium reserves the band (`offsetWidth - clientWidth` is 8) but paints no thumb into a captured frame — measured by counting thumb-coloured pixels in the band across the reveal, which stays at noise level in headless and jumps from 46 to 1466 in a headed run.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Agent Note:侧边栏的滚动条跟随指针
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-04-pointer-revealed-sidebar-scrollbars.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
侧边栏的会话列表只要有十来个会话就会溢出,从那一刻起它的滚动条就一直画在那里——所处的这一列大部分时间都是静止的,而列表行自己的操作按钮只在悬停时才出现。它是侧边栏里唯一始终常驻的构件,而在有人真的伸手去拖它之前,它不提供任何可操作性。产品诉求(2026-08-04)是只在指针位于侧边栏内时才绘制它,并留一小段拖尾,避免指针路过时它一闪而灭。
|
||||
|
||||
## Decision
|
||||
|
||||
`SidebarRoot` 跟踪整列上的指针,只要指针不在列内就给根元素挂上 `quietBars` 类。该类选中的规则把 ui-theme 的那组间接变量——`--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`——重新绑定为 `transparent`,于是嵌套在这一列下的每个滚动区域都不绘制滑块。今天这样的区域只有会话列表;将来新增的区域会直接继承这一行为,而不需要逐个接入。
|
||||
|
||||
拖尾是 `SCROLLBAR_LINGER_MS = 2000`:离开会启动一个定时器,进入会取消尚未触发的定时器,只有定时器真正触发才会把类加回去。指针越过列边界又折返时——绕过一个 portal 菜单,或是奔向某一行时冲过了头——不会看到滑块闪动。
|
||||
|
||||
进入用的是列自身的 `pointerenter`;离开则按列的盒子判定,由一个只在滚动条可见期间存在的 `pointermove` 监听完成。DOM 包含关系无法判定离开:ui-settings 把整屏的设置面板渲染为这一列的 fixed 定位**后代**,指针移到该面板上——或在面板关闭后移到对话区——都不会在这里触发 `pointerleave`,滚动条就会继续画在一个没人指向的列上。元素自身的 leave 仍然保留,用于几何判定看不到的那一种情况:指针移出窗口后不再产生任何移动事件。
|
||||
|
||||
承载指针的是整列,而不是列表。奔向滚动条的指针会先经过 logo 行、New Session 胶囊和搜索框,所以只在列表上显示,会让滚动条等到指针已经落在行中间时才出现。
|
||||
|
||||
`transparent` 正是让这次显示不触发任何布局的原因。列表上的 `scrollbar-gutter: stable` 存在的意义就是让行永不移动(见[空槽 Agent Note(agent 决策记录)](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md));重新绑定的只是颜色,那份预留始终有效,所以滑块出现在列表本就为它留出的空间里。
|
||||
|
||||
选择这组间接变量而不是给列表加规则,是因为这组变量正是 ui-theme 写明的重新绑定接缝:一次声明同时作用于两条渲染路径(WebKit 伪元素与 Firefox 的 `scrollbar-color`),而自定义属性会继承——这正是让整列、而不是列内每个滚动区域,成为该状态所有者的原因。
|
||||
|
||||
这拓宽了重新绑定契约,因此它的门禁把新的形态明写出来,而不是默许通过:`ui-theme/tests/scrollbar-styles.spec.ts` 只接受两种重新绑定目标,即 l2 那一组或 `transparent`,并且判定的是**整条规则**而不是逐条声明——混合规则(`thumb: transparent` 与 l2 的 hover 并列)会在指针一碰到滚动条时重新上色,却能通过逐条检查。抬升那一半按整个值与这组变量的规范写法比对,这同时也拒绝了交叉绑定和被包在字面表达式里的 token;绑回 l1 与裸颜色本来就在门外。
|
||||
|
||||
隐藏不再算作抬升:只有 l2 重绑才能让一张样式表免于「任何既滚动又绘制抬升表面的样式表都必须重新绑定」。既隐藏滚动条又在抬升表面上滚动的样式表,仍然欠着那里真正绘制滑块时所需的 l2。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**只用列上的 CSS `:hover`,不引入 JavaScript 状态。** 整套机制只需一条规则,但它表达不出拖尾:指针越过边界的那一帧滑块就会消失,而那恰好是指针正奔向对话区或绕行 portal 菜单的时刻。诉求本身点名了拖尾,只有 hover 的版本读起来就是闪烁。
|
||||
|
||||
**留在 CSS 里、用过渡拿到这段延迟**,即通过 `@property` 注册 `--dsh-scrollbar-thumb` 让该自定义属性可动画,再用 `transition-delay` 把颜色按住。因代价与作用范围被否决:这项注册对每个读取这组变量的表面都是全局的,却只为一列的时序服务;而且这套调色板实际渲染所走的 WebKit 滚动条伪元素并不可靠地支持过渡——延迟会被声明在观察不到它的地方。
|
||||
|
||||
**直接把滚动条藏掉**——`scrollbar-width: none`,或对 `::-webkit-scrollbar` 用 `display: none`。被否决,因为这会连带取消那段预留:滚动条重新出现时要重新占走 8px,会把每一行都在显示它的那个指针底下横向推移,而这正是当初加入空槽预留所修掉的回归。
|
||||
|
||||
**在应用内自绘一个覆盖式滑块**,并彻底隐藏原生滚动条,这是完全自定义淡入淡出所需要的做法。它换来任意样式,代价是命中测试、拖拽、滚轮、惯性以及两套调色板下的 hover 状态——在一个滚动条已由 token 统一主题化的客户端里,为观感付出的是一大片自持表面。
|
||||
|
||||
**把显示范围收敛到滚动的列表而不是整列。** 涉及的元素更少,却把显示的边界放错了位置:指针最后才到达行,滚动条会等到用户已经在读这些行时才出现;而且日后加入侧边栏的其他滚动区域都得手工接入。
|
||||
|
||||
**滚动事件也触发显示**,让键盘或触摸驱动的滚动同样显示滚动条。被否决,因为那是在为触发它的输入方式画一个它用不上的可供性;行本身已经说明列表移动过了。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 用键盘或触摸拖动滚动的列表,在拖尾结束后不显示滑块,因为这两种方式都不会把指针留在列上。e2e 会钉住这一点,而不只是把它写下来。
|
||||
- 拖动滑块本身移出列不会在拖动中途把它隐藏:滚动条会接管指针捕获,按住按键期间页面收不到 `pointermove`。已在 Chromium 实测——指针拖到列右侧 900px 处、超过拖尾窗口后,滚动条依然绘制并继续滚动。
|
||||
- 冷启动时该列处于静默状态,直到指针第一次移到它上面为止。页面加载时就停在那里的指针在移动之前不会触发任何事件,这是浏览器的规则,而非这个外壳的。
|
||||
- 嵌套在列内、为自身抬升层级把这组变量重新绑定到 l2 的抬升表面,会覆盖静默状态并继续绘制自己的滚动条。今天侧边栏内没有这样的表面。
|
||||
- 外壳的 DOM 现在带有一个状态类,因此 ui-sidebar 的外壳快照会钉住 `quietBars`,默认状态出现回归时表现为快照 diff,而不是需要有人从截图里看出来的东西。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx` 用假定时器把这个类走过各次跃迁:进入时显示,拖尾结束前 1 毫秒仍然显示,结束后 1 毫秒转为静默,以及窗口内折返会取消隐藏。另有两条覆盖几何判定的离开:落在列盒子之外的 `pointermove` 会在没有任何 DOM leave 的情况下隐藏滚动条(即设置面板那种形态),落回盒子之内的则取消待触发的隐藏。它还在拖尾进行中卸载组件并断言没有定时器存活——待触发的隐藏落到已销毁的组件上,正是这种写法容易犯的错。事件用的是带 `relatedTarget` 的 `pointerover`/`pointerout`,因为 React 由它们合成 enter 与 leave,而会忽略原生的那两个事件。
|
||||
|
||||
`packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts` 直接读样式表:该规则必须写出这组变量的两半——只重新绑定静止态滑块,会让指针一碰到滚动条就露出 hover 颜色——并且不得出现 `scrollbar-gutter`,那属于滚动区域自己。
|
||||
|
||||
`apps/web/tests/sidebar-scrollbar.e2e.ts` 是两半在真实引擎里汇合的地方。它在每次读取颜色前先把指针停在列表上,因为一个从不移动鼠标的场景全程测到的都是静默状态,会变成空洞的绿。随后它自己的用例把指针移开,断言在 leave 当下滑块仍在绘制,轮询直到它解析为 `rgba(0, 0, 0, 0)`,在该状态下重新测量几何以证明滚动条隐藏期间那份预留依然生效,并以编程方式滚动列表——键盘或触摸拖动所做的事——来钉住无指针滚动不绘制任何滑块。提交的 golden 记录了两套调色板下、两个指针位置上的滑块颜色。
|
||||
|
||||
这条 e2e 的对照是一次 mutation,而它需要插件自己的产物:把 `quietBars` 从外壳中去掉,先重新构建 `@deepseek-ai/dsh-client-ui-sidebar`、之后再跑 `build:web`,该用例会因为滑块解析为 `rgb(229, 229, 229)`、而期望 `rgba(0, 0, 0, 0)` 而变红。只重跑 `build:web` 用的是过期产物,即使改动已被删除也照样通过,这正是[空槽 Agent Note(agent 决策记录)](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)记录过的陷阱。
|
||||
|
||||
拓宽后的门禁也有自己的对照,每个都是对真实样式表的一处声明改动:把 `transparent` 与 l2 的 hover 混用,以及把 l2 token 包进 `color-mix(…)`,都会让这条成对断言变红。
|
||||
|
||||
演示这一行为的录制必须用有头浏览器。无头 Chromium 会预留那条带(`offsetWidth - clientWidth` 为 8),却不会把滑块画进捕获帧——通过统计带内滑块色像素在显示前后的变化实测:无头一直停在噪声水平,有头则从 46 跳到 1466。
|
||||
@@ -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-08-04-web-composer-shared-width-axis.md
|
||||
2026-08-04-web-composer-shared-width-axis.md: 96cddda25bf79e9f2df7a0298039c27f316befca
|
||||
2026-08-04-web-composer-shared-width-axis.zh.md: 9a9f5a513bbce8f3e97698d58ab48b0abdefb142
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Web composer shared width axis and control-row polish
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-04-web-composer-shared-width-axis.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The web conversation column sized each surface independently: the transcript column, the input card, the todo/goal/queue dock cards, and the ask-question/approval/plan-review takeover cards each carried their own hardcoded max-width (736/752/776/800px variants) and their own side paddings. The surfaces drifted a few pixels apart at full width and diverged further on narrow viewports, where some panels kept clearance from the screen edge and others went flush. Separately, the composer's control row had no adaptive behavior — on a narrow card the permission trigger's label squeezed the row — and the overlay menus anchored to the card could render wider than the card itself, painting past its right edge.
|
||||
|
||||
## Decision
|
||||
|
||||
One content width variable owns the whole column. `--dsh-chat-content-width` (748px) is declared on ConversationRoot's `.root` — the transcript and the composer seat are sibling subtrees, so the declaration must sit on their common ancestor for CSS custom-property inheritance to reach both. Every other geometry derives from it: the input card caps at `content + 32px` (`--dsh-composer-card-max-width`), the dock cards subtract four dock insets (4 × 8px) from the card and land back on the content width, and the takeover cards use the content width directly. The narrow-viewport invariant is expressed structurally, not numerically: content-width surfaces pad `calc(var(--dsh-composer-side-clearance) + 16px)` per side while the input card clears the bare clearance (16px), so "input card = content + 32px" holds at every viewport width, not just at the cap.
|
||||
|
||||
The control row inside the card is a `container-type: inline-size` container, and the permission trigger drops its text label (keeping glyph + chevron) under a 460px container query. The query is anonymous on purpose: CSS modules hash `container-name` per module, so a name declared in InputBar's sheet can never match a query written in PermissionSelect's sheet — the two hashed names silently differ and the query never fires. Only triggers that carry a mode glyph collapse (`:has(.triggerIcon)`); a host-configured mode without one keeps its text as its sole identifier.
|
||||
|
||||
Overlay menus anchored to the card (slash menu, command popupSelect) clamp to the anchor's width (`max-width: min(<design cap>, 100%)`), truncating long rows with ellipses instead of overflowing the card. Tooltip bubbles keep a 12px viewport-edge safety margin in the clamp (ui-primitives Tooltip).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep per-surface widths and align the numbers by hand.** Rejected: the drift this change removes was exactly the residue of hand-aligned constants; any future width change would need five coordinated edits with nothing enforcing the relation.
|
||||
|
||||
**Declare the variables on `.composerStack`.** Rejected after trying it: the takeover panels are siblings of the stack in the composer seat and the transcript is a different subtree entirely, so the variables never reached them; the common ancestor (`.root`) is the only correct home.
|
||||
|
||||
**A named container query for the label collapse.** Rejected by measurement: CSS modules scope `container-name` per module, so the cross-module name never matched and the query was dead. The anonymous query resolves against the nearest ancestor container, which is unambiguous here (the row is the only container).
|
||||
|
||||
**JS ResizeObserver for the label collapse.** Rejected: a container query is declarative, needs no listener lifecycle, and the 460px threshold is a design choice either way.
|
||||
|
||||
## Consequences
|
||||
|
||||
Changing the column width is now a one-line edit with the ratio relations preserved by construction, which the 736 → 748 retune during review already exercised. The cost is indirection: the widths of five surfaces are no longer readable off their own stylesheets and require following the variable chain to ConversationRoot. The container-query collapse adds the constraint that InputBar's row stays a size container; removing that declaration silently disables the permission trigger's adaptive behavior. The anonymous query also means any future second container between the row and the trigger would capture it — if that happens, the query must move or the intermediate container must be avoided.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Web 输入区共享宽度轴与控制行打磨
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-04-web-composer-shared-width-axis.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Web 会话列的各个界面各自独立设定尺寸:转录列、输入卡片、todo/goal/queue 停靠卡片、ask-question/approval/plan-review 接管卡片各自硬编码 max-width(736/752/776/800px 等变体)与各自的侧边内边距。这些界面在全宽下彼此漂移几个像素,在窄视口下偏差更大——有的面板保留了到屏幕边缘的间隙,有的却贴边。另外,输入卡片的控制行没有自适应行为——窄卡片下权限触发器的文字标签会挤压整行;锚定在卡片上的浮层菜单也可能渲染得比卡片更宽,越过其右边缘。
|
||||
|
||||
## Decision
|
||||
|
||||
一个内容宽度变量拥有整列。`--dsh-chat-content-width`(748px)声明在 ConversationRoot 的 `.root` 上——转录与 composer 座位是兄弟子树,声明必须放在共同祖先上,CSS 自定义属性才能通过继承同时到达两者。其他几何全部由它推导:输入卡片上限为 `content + 32px`(`--dsh-composer-card-max-width`),停靠卡片从卡片宽度中减去四个停靠 inset(4 × 8px)正好回到内容宽度,接管卡片直接使用内容宽度。窄视口不变式以结构而非数值表达:内容宽度的界面每侧 pad `calc(var(--dsh-composer-side-clearance) + 16px)`,而输入卡片只留裸 clearance(16px),因此"输入卡片 = 内容 + 32px"在任意视口宽度下都成立,而不只是在上限处。
|
||||
|
||||
卡片内的控制行是一个 `container-type: inline-size` 容器,权限触发器在 460px 容器查询下收起文字标签(保留图标 + 下拉箭头)。查询刻意匿名:CSS modules 按模块哈希 `container-name`,InputBar 样式表里声明的名字永远无法匹配 PermissionSelect 样式表里写的查询——两个哈希后的名字悄然不同,查询永不触发。只有带模式图标的触发器才收起(`:has(.triggerIcon)`);没有图标的宿主自定义模式保留文字作为其唯一标识。
|
||||
|
||||
锚定在卡片上的浮层菜单(slash 菜单、command popupSelect)钳制到锚点宽度(`max-width: min(<设计上限>, 100%)`),过长的行以省略号截断而不是溢出卡片。Tooltip 气泡在钳制中保留 12px 的视口边缘安全距离(ui-primitives Tooltip)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留各界面独立宽度,手工对齐数值。** 否决:本次改动消除的漂移正是手工对齐常量的残留;未来任何宽度调整都需要五处协同编辑,且没有任何机制强制这组关系。
|
||||
|
||||
**把变量声明在 `.composerStack` 上。** 尝试后否决:接管面板在 composer 座位中是 stack 的兄弟节点,转录更是完全不同的子树,变量根本到不了它们;共同祖先(`.root`)是唯一正确的家。
|
||||
|
||||
**用命名容器查询实现标签收起。** 经实测否决:CSS modules 按模块作用域化 `container-name`,跨模块名字永不匹配,查询是死的。匿名查询解析到最近的祖先容器,在这里没有歧义(该行是唯一的容器)。
|
||||
|
||||
**用 JS ResizeObserver 实现标签收起。** 否决:容器查询是声明式的,无需监听器生命周期,而 460px 阈值无论哪种方案都是设计选择。
|
||||
|
||||
## Consequences
|
||||
|
||||
修改列宽现在是一行编辑,比例关系由构造保证——评审期间 736 → 748 的重调已经验证了这一点。代价是间接性:五个界面的宽度不再能从各自的样式表直接读出,需要沿变量链追到 ConversationRoot。容器查询收起增加了一个约束:InputBar 的行必须保持为尺寸容器;删掉那条声明会静默禁用权限触发器的自适应行为。匿名查询也意味着未来若在行与触发器之间出现第二个容器,它会截获该查询——届时查询必须迁移,或避免中间容器。
|
||||
Reference in New Issue
Block a user