Merge remote-tracking branch 'origin/master' into worktree/pr628-merge-20260727

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
#	docs/architecture.i18n.yaml
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	examples/headless-agent/tests/headless.snapshot.ts
#	packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/examples/agent-spine-demo/README.md
#	packages/llm/README.md
#	packages/llm/llm-deepseek/src/adapter.ts
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm-retry/README.md
#	packages/llm/llm/README.md
#	packages/llm/llm/src/index.ts
#	packages/llm/llm/tests/service.spec.ts
#	packages/support/llm-replay/src/index.ts
#	packages/support/llm-replay/tests/llm-replay.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-27 22:44:43 +08:00
2019 changed files with 66577 additions and 16794 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-19-windows-atomic-write-dacl-preservation.md: 013119508da9be426c417797cf7a0ec14e276814
2026-07-19-windows-atomic-write-dacl-preservation.zh.md: 8ae82884c3b80409d07d3bbcfc8c273e8b227dc8
2026-07-19-windows-atomic-write-dacl-preservation.md: be9f82174300a7d605c6a6e63878728f08cb37be
2026-07-19-windows-atomic-write-dacl-preservation.zh.md: fc6ec5232c992f3a230ee0de89439c869b8b46f9
@@ -6,13 +6,13 @@ English | [中文](2026-07-19-windows-atomic-write-dacl-preservation.zh.md)
## Problem
On Windows, creating the staging directory and temp file under the target's parent and relying only on inherited DACLs is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement.
Atomic writes protect POSIX staging directories with `0o700` and temp files with `0o600`, but Windows mode bits expose only a synthetic read-only view of the actual DACL. Creating staging under the target's parent and relying on inheritance is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement.
## Decision
`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New files have no prior descriptor to preserve and continue to inherit the destination directory's DACL.
`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New Windows files have no prior descriptor to preserve and continue to inherit the destination directory's DACL; their staging directory therefore lives beside the target. POSIX keeps the owner-only staging modes and preserves an existing target mode.
Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary.
Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary. Mode-bit assertions remain POSIX-only; new-file DACL inheritance is an operating-system contract rather than a machine-specific account allowlist.
## Alternatives considered
@@ -22,6 +22,10 @@ Native Windows coverage protects a target DACL, inspects the written staging fil
**Install an owner-only DACL for every write.** Rejected because it would discard deliberate project sharing. Copying the target DACL preserves the deployment's existing access policy instead of inventing one.
**Assert inherited accounts with `Get-Acl` or `icacls`.** Rejected because such a test verifies machine policy rather than package behavior, and localized well-known account names make the output unstable across hosts.
**Skip the existing `chmod` calls on Windows.** Rejected because Node maps these writable modes to benign no-ops; platform guards add branches without changing DACL behavior.
## Consequences
Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. New-file behavior remains directory-inherited, and POSIX mode behavior is unchanged.
Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. A new Windows file inherits broad directory access when the directory is broad by design, while POSIX temp content stays owner-only; a read-only Windows target still fails publication before synthetic mode replay could matter.
@@ -6,13 +6,13 @@ Status: implemented
## 问题
在 Windows 上,在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。
原子写入在 POSIX 上以 `0o700` 保护暂存目录、以 `0o600` 保护临时文件,但 Windows mode 位只呈现实际 DACL 的合成只读视图。在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。
## 决策
`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新文件没有既有描述符需要保留,因此仍继承目标目录的 DACL。
`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新的 Windows 文件没有既有描述符需要保留,因此仍继承目标目录的 DACL;其暂存目录也因此位于目标文件旁。POSIX 继续使用仅所有者可访问的暂存 mode,并保留现有目标文件的 mode
Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。
Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。mode 位断言仍仅适用于 POSIX;新文件的 DACL 继承由操作系统契约规定,不应通过特定机器的账户允许列表来断言。
## 备选方案
@@ -22,6 +22,10 @@ Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成
**每次写入都设置仅所有者可访问的 DACL。** 不予采用,因为这会破坏项目有意设置的共享权限。复制目标文件的 DACL 可以保留部署中已有的访问策略,无需另行创设策略。
**使用 `Get-Acl` 或 `icacls` 断言继承账户。** 不予采用,因为这类测试验证的是机器策略,而不是包行为;系统内置账户名会本地化,使输出在不同宿主上不稳定。
**在 Windows 上跳过现有 `chmod` 调用。** 不予采用,因为 Node 会把这些可写 mode 映射为无害的空操作;平台条件判断只会增加分支,不会改变 DACL 行为。
## 影响
替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新建文件仍按目录继承,POSIX mode 行为保持不变
替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新的 Windows 文件会在目录按设计开放较宽访问权限时继承该权限,而 POSIX 临时内容仍仅允许所有者访问;只读 Windows 目标文件仍会在发布时失败,早于重放合成 mode 可能产生影响的时点
@@ -1,6 +0,0 @@
# 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
2026-07-20-code-mode-result-card-completeness.md: 97cd9d722e8252b956e16da03c3b8418451350f3
2026-07-20-code-mode-result-card-completeness.zh.md: fea162b073e3473f7a07d4bf054408d28851fea9
@@ -1,39 +0,0 @@
# Agent Note: Keep the Code Mode result card complete
Status: implemented
English | [中文](2026-07-20-code-mode-result-card-completeness.zh.md)
## Problem
The outer `run_code` tool persisted complete rendered content, but its UI presenter ignored that content and rebuilt the card body from a logs-only `presentationMeta` projection. A result-only run appeared correct because an empty presenter body let consumers fall back to `tool/result.content`. Once the program emitted a log, the presenter supplied non-empty content, that fallback stopped, and the returned value disappeared from the completed card. A spill policy's final head/tail preview was vulnerable to the same split ownership whenever captured logs made the stale projection non-empty.
Nested Code calls never owned cards, so producing metadata for the outer call solely to reconstruct one incomplete card also obscured the intended one-card boundary.
## Decision
The canonical tool registry pipeline owns the final model-facing outer content. On success, the `run_code` output renderer renders captured logs followed by the return value or the explicit no-output marker. Runtime failures and pre-execution policy denials are normalized into error content by `ToolRegistry` without invoking that renderer. A post-execute block runs after successful rendering and replaces the result with error content; other post-execute policy and spill decisions may replace content before persistence.
`run_code` omits `presentResult`. The established generic result fallback keeps the pending program title and renders the raw final `tool/result.content`; that durable, replayable, post-policy projection is the card's only result-content source. The host API proxy therefore omits a separate result view instead of serializing the same content in both `event.data.content` and `view.view.content`. The redundant logs-only `presentationMeta` projection remains removed.
Nested dispatch remains unchanged. Calls marked by `exec.parent` emit bounded `tool/code-dispatch` diagnostics but no `tool/call` or `tool/result` surface cards, so one outer `run_code` invocation still produces exactly one card.
## Testing
Tool unit coverage drives logs-only, result-only, logs-plus-result, no-output, spilled-result, and failure outcomes through the canonical registry, then pins the durable content and absence of a result presenter. A host-mux regression uses a call-only presenter to prove the result frame carries raw content exactly once and no view. These cases prove stale metadata cannot replace final content without making the host duplicate that content.
The keyless ACP backend and TUI Code Mode snapshots execute one outer program that performs two nested bash calls, logs `captured output`, and returns `CODE_ONE+CODE_TWO`. The persisted ACP log pins the complete result; the TUI surface shows one completed outer card containing both lines and no nested cards.
## Alternatives considered
**Append the return value to logs metadata.** Rejected because metadata would duplicate the renderer, need a second stable formatting contract for every JSON root, and still miss post-policy content replacement or spill previews.
**Merge presenter metadata with `result.content`.** Rejected because the rendered content already contains the logs; merging would duplicate them and require brittle deduplication.
**Forward `result.content` through a generic result presenter.** Rejected because the durable event already carries that content and UI consumers already have a generic raw-content fallback. The host mux serializes a tool-owned result view beside the event, so forwarding would duplicate the rendered content in one frame merely to recreate the fallback; the default worker alone admits a 64 MiB variable-payload budget before rendering.
**Create one card per nested dispatch.** Rejected because intermediate values are intentionally execution-local and never model-facing. Multiple cards would expose an implementation trace instead of the single Code Mode operation the model and user invoked.
## Consequences
TUI and JSON-RPC/Web display the same complete content the model receives and replay persists, including post-policy spill previews, through their generic result fallback. The host API retains the pending program title without duplicating the raw result in a separate view payload. New `run_code` results no longer carry the optional logs metadata, but this requires no session-format bump: existing records remain valid because presentation reads their durable rendered content.
@@ -1,39 +0,0 @@
# Agent Note: 保证 Code Mode 结果卡片内容完整
Status: implemented
[English](2026-07-20-code-mode-result-card-completeness.md) | 中文
## 问题
外层 `run_code` 工具会持久化完整的渲染内容,但其 UI 展示器忽略了这些内容,转而根据仅含日志的 `presentationMeta` 投影重新构建卡片正文。仅有结果的运行看似正确,是因为展示器正文为空时,消费方会回退到 `tool/result.content`。只要程序输出一条日志,展示器就会提供非空内容,回退随即停止,返回值便会从完成态卡片中消失。当已捕获的日志使陈旧投影变为非空时,输出落盘策略最终生成的头尾预览也会受到同一职责拆分的影响。
嵌套 Code 调用从不生成自己的卡片。因此,仅仅为了重建这一张不完整卡片而给外层调用生成元数据,还掩盖了每次外层调用只生成一张卡片的预期边界。
## 决策
规范的工具注册表流水线负责最终面向模型的外层内容。成功时,`run_code` 输出渲染器先渲染已捕获的日志,然后渲染返回值或显式的无输出标记。运行时失败和执行前策略拒绝由 `ToolRegistry` 归一化为错误内容,过程中不会调用该渲染器。Post-execute 阻断发生在成功渲染之后,并把结果替换为错误内容;其他 post-execute 策略与输出落盘决策可以在持久化之前替换内容。
`run_code` 不提供 `presentResult`。既有的通用结果回退机制会保留待完成的程序标题,并渲染原始的最终 `tool/result.content`;这一持久、可回放且经过 post-policy 处理的投影是卡片中结果内容的唯一来源。宿主 API 代理因此不提供单独的结果视图,而不会在 `event.data.content``view.view.content` 中重复序列化同一内容。冗余的仅含日志的 `presentationMeta` 投影继续保持移除状态。
嵌套分发保持不变。带有 `exec.parent` 标记的调用会发出有界的 `tool/code-dispatch` 诊断,但不会生成与 `tool/call``tool/result` 对应的界面卡片,因此一次外层 `run_code` 调用仍然只会生成一张卡片。
## 测试
工具单元测试通过规范注册表覆盖仅有日志、仅有结果、日志与结果并存、无输出、结果落盘和失败的结果,然后固定持久内容以及结果展示器不存在这一事实。宿主 mux 回归测试使用仅有调用的展示器,证明结果帧恰好携带一次原始内容,且不含视图。这些案例证明陈旧元数据无法替换最终内容,同时不会让宿主重复该内容。
无密钥的 ACPAgent Client Protocol)后端快照与 TUI Code Mode 快照会执行一个外层程序:程序进行两次嵌套 bash 调用,记录 `captured output`,并返回 `CODE_ONE+CODE_TWO`。ACP 持久化日志固定完整结果;TUI 界面只显示一张完成态外层卡片,其中包含这两行内容,且没有嵌套卡片。
## 备选方案
**把返回值追加到日志元数据:**不予采纳。元数据会与渲染器重复,并且需要为每一种 JSON 根另行维护稳定的格式化契约;post-policy 内容替换或输出落盘预览仍然会被遗漏。
**把展示元数据与 `result.content` 合并:**不予采纳。渲染内容已经包含日志;合并会造成重复,还需要依赖脆弱的去重逻辑。
**通过通用结果展示器转发 `result.content`:**不予采纳。持久事件已经携带该内容,UI 消费方也已有通用的原始内容回退机制。宿主 mux 会在事件旁序列化工具拥有的结果视图,因此转发仅仅是为了重建该回退机制,却会在一个帧中重复渲染内容;仅默认 worker 在渲染前允许 64 MiB 的可变载荷预算。
**为每次嵌套分发创建一张卡片:**不予采纳。中间值有意只存在于执行期间,永远不面向模型。多张卡片会暴露实现轨迹,而不是模型与用户调用的单次 Code Mode 操作。
## 影响
TUI 与 JSON-RPC/Web 通过通用结果回退机制显示与模型接收及回放持久化相同的完整内容,其中包括 post-policy 输出落盘预览。宿主 API 保留待完成的程序标题,同时不在单独的视图负载中重复原始结果。新的 `run_code` 结果不再携带可选的日志元数据,但无需提升会话格式版本:现有记录仍然有效,因为展示逻辑会读取其中持久化的渲染内容。
@@ -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
2026-07-21-semantic-session-checkpoints.md: 4bca02fe3893ac39621ed79a000ca8f86db4ff67
2026-07-21-semantic-session-checkpoints.zh.md: 1f187eb6448a3c9ca6784ec2bddd7295be2706d7
2026-07-21-semantic-session-checkpoints.md: 0034cde40e5b07bda1573ca39fb7d51816006140
2026-07-21-semantic-session-checkpoints.zh.md: 3351221d7eeaf1353b4adb0fa4c4dc324ec33da5
@@ -26,4 +26,4 @@ Flushing every event or streaming chunk minimizes loss but turns local append an
## Consequences
Hard-crash recovery retains the complete model request, durable tool intent, and complete settled step at the nearest semantic boundary while allowing partial streaming chunks since the previous boundary to remain lossy. Default CLI, TUI, ACP, Python SDK runtime, headless persistence tests, and JSON-RPC compositions mount the policy with their persistence backend. Unit tests cover ordering, cancellation during a checkpoint, fail-closed behavior, nested dispatch, disposal, and Loader shape; a real child process killed with `SIGKILL` proves request and tool-intent recovery through JSONL, and the shared persistence contract proves both recovery classifications across backends. Keyless ACP snapshots prove both that retry-risk guidance reaches resumed history and the next model turn and that graceful cancellation persists the loop's real closing boundaries.
Hard-crash recovery retains the complete model request, durable tool intent, and complete settled step at the nearest semantic boundary while allowing partial streaming chunks since the previous boundary to remain lossy. Default CLI, TUI, ACP, Python SDK runtime, headless persistence tests, and JSON-RPC compositions mount the policy with their persistence backend. Unit tests cover ordering, cancellation during a checkpoint, fail-closed behavior, nested dispatch, disposal, and Loader shape; a real child process killed with `SIGKILL` proves request and tool-intent recovery through JSONL, and the shared persistence contract proves both recovery classifications across backends. The crash harness waits for the expected marker contents rather than path existence, so open-before-write visibility cannot trigger the kill early. Keyless ACP snapshots prove both that retry-risk guidance reaches resumed history and the next model turn and that graceful cancellation persists the loop's real closing boundaries.
@@ -26,4 +26,4 @@ ACPAgent Client Protocol)应用在一个有序 Cordis effect 中统一持
## 后果
发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACP、Python SDK 运行时、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、检查点期间的取消、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化契约则证明各后端都支持这两种恢复分类。无密钥 ACP 快照既证明重试风险指引会进入恢复后的历史记录与下一个模型轮次,也证明取消流程正常收尾时,系统会持久化由循环实际生成的闭合边界。
发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACP、Python SDK 运行时、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、检查点期间的取消、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化契约则证明各后端都支持这两种恢复分类。崩溃 harness 会等待预期的标记内容,而不是仅等待路径存在,因此文件在写入前因打开而可见时,不会导致该 harness 提前终止子进程。无密钥 ACP 快照既证明重试风险指引会进入恢复后的历史记录与下一个模型轮次,也证明取消流程正常收尾时,系统会持久化由循环实际生成的闭合边界。
@@ -1,6 +0,0 @@
# 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
2026-07-22-collapsed-sidebar-control-rail.md: 940fcabf126941cc0e411b01c337e45831e442aa
2026-07-22-collapsed-sidebar-control-rail.zh.md: 70ace36fafcb28aa714000262e31c8555d394854
@@ -1,29 +0,0 @@
# Agent Note: A collapsed sidebar retains its control rail
Status: implemented
English | [中文](2026-07-22-collapsed-sidebar-control-rail.zh.md)
## Problem
The sidebar close action persisted a zero width preference, and the layout mapped that preference to a zero-width grid track. The only sidebar toggle and the settings entry both lived inside that clipped track, so closing the sidebar removed every visible recovery control. Reloading preserved the closed preference and reproduced the lockout.
## Decision
The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 56px: a 24px icon column between the sidebar's 16px horizontal paddings. The sidebar track is fixed-width in the solver — open or collapsed it never concedes to viewport pressure (only details shrinks, then auto-closes) — and the rail retains its right border while the stored expanded width remains untouched.
`AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. Collapse and expand animate: the frame transitions `grid-template-columns` (and the remaining handle its `left`) on the deepsuite sider curve — `--ds-ease-in-out` over `--ds-transition-duration-slow`, both supplied by ui-theme's base sheet; transitions pause during drags and under `prefers-reduced-motion`.
`SidebarRoot` reads the owner `collapsed` prop and transitions as a slide + crossfade: the expanded content freezes at its width (inline style) and fades out in place over 150ms while the sliding grid column clips it — nothing reflows mid-slide. At settle the wide-only content (brand, labels, input, session tree) unmounts — dropping the sessions subscription and leaving the rendered and accessibility trees — and the control rows snap to the rail (open toggle, new session, new workspace, search, the same top-down order as their expanded rows) fading in as the slide ends. Each rail control keeps its expanded counterpart's behavior (the search icon expands the sidebar and focuses the search box after the slide), carries a tooltip, and the toggle rests as the whale mark with the panel icon on hover. The search query lives with the root and survives the round trip.
## Alternatives considered
- **Render an expand button over the center column** — rejected because it recovers only the toggle, not the persistent settings area, and splits sidebar chrome across two package owners.
- **Keep a zero-width grid track and let the rail overflow it** — rejected because the rail would overlap the center column and leave hit testing and responsive geometry disconnected from the grid.
- **Keep the complete sidebar tree mounted and hide it with clipping** — rejected because hidden controls remain in the semantic tree and continue subscribing and rendering even though only two controls belong in the collapsed state.
## Consequences
- A collapsed sidebar reserves 56px instead of yielding the entire width to the center column. Expanding restores the persisted width and drag behavior.
- The settings entry remains visible but retains its existing placeholder behavior; this change does not introduce an account or settings screen.
- Layout solver tests pin the compact width, sidebar component tests pin the visible controls, and the keyless real-bundle web smoke test pins collapse and recovery through the assembled client.
@@ -1,29 +0,0 @@
# Agent Note: 侧边栏折叠后保留控制栏
Status: implemented
[English](2026-07-22-collapsed-sidebar-control-rail.md) | 中文
## 问题
侧边栏关闭操作会持久化宽度偏好 `0`,布局再将该偏好映射为宽度为零的网格轨道。侧边栏唯一的开关与设置入口都位于这个被裁切的轨道内,因此关闭侧边栏会移除所有可见的恢复控件。页面重新加载时仍会读取关闭偏好,从而再次陷入无法恢复的状态。
## 决策
布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。侧边栏轨道在求解器中是定宽的——无论展开还是折叠都不向视口压力让步(只有 details 会收缩、继而自动关闭);控制栏保留右侧边框,已存储的展开宽度保持不变。
`AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。折叠与展开带动画:frame 对 `grid-template-columns`(以及余下手柄的 `left`)应用 deepsuite 侧栏曲线过渡——`--ds-ease-in-out``--ds-transition-duration-slow`,两个变量由 ui-theme 的 base 表提供;拖拽期间和 `prefers-reduced-motion` 下过渡暂停。
`SidebarRoot` 读取 owner 的 `collapsed` 属性,过渡是滑动 + 交叉淡变:展开内容以内联样式冻结在原宽度、150ms 原地淡出,滑动中的网格列裁切它——滑动途中不发生任何重排。settle 时宽态专属内容(品牌标识、文字标签、输入框、会话树)卸载——随之退订会话列表并离开渲染树与可访问性树——控件行落位到控制栏(打开开关、新建会话、新建工作区、搜索,自上而下与展开态各行顺序一致),随滑动结束淡入。每个控制栏控件保持与展开态对应控件一致的行为(搜索图标展开侧边栏并在滑动结束后聚焦搜索框)并带 tooltip;开关静止时显示鲸鱼标,悬停切换为面板图标。搜索关键词由根组件持有,折叠往返后保留。
## 曾考虑的替代方案
- **在中心列上方渲染展开按钮**:不予采纳,因为这只能恢复开关,无法保留常驻设置区域,同时还会让侧边栏 UI 由两个包(package)分别持有。
- **保留宽度为零的网格轨道,让控制栏溢出显示**:不予采纳,因为控制栏会与中心列重叠,还会使命中测试和响应式几何关系脱离网格布局。
- **保持完整侧边栏树挂载,并通过裁切将其隐藏**:不予采纳,因为隐藏控件仍留在语义树中,而且会继续订阅和渲染,尽管折叠状态下只需要两个控件。
## 后果
- 折叠的侧边栏占用 56px,而不是把全部宽度让给中心列。展开时恢复持久化宽度与拖动行为。
- 设置入口持续可见,但保留既有占位行为;本次改动不提供账户或设置页面。
- 布局求解器测试固定紧凑宽度,侧边栏组件测试固定可见控件,基于真实构建产物的无密钥 Web 冒烟测试则通过组装后的客户端固定折叠与恢复行为。
@@ -1,6 +0,0 @@
# 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
2026-07-23-demo-web-builds-client-bundles.md: a7d21987d4544246fd3c53864cedfc86279e9440
2026-07-23-demo-web-builds-client-bundles.zh.md: f10184642b0c7869378802d3040ebf4dbe67d4e0
@@ -1,27 +0,0 @@
# Agent Note: demo:web builds the client plugin bundles
Status: implemented
English | [中文](2026-07-23-demo-web-builds-client-bundles.zh.md)
## Problem
`dsh web` serves each web-client plugin's bundle from `GET /plugins/<id>/client.js`, resolving the path from the package's `exports["./client"]` (`lib/client.js`). Those bundles are produced only by the root `pnpm run build` (`tsc -b` then the per-package `tsdown.client.ts` configs); the Vite `build:web` step builds the frontend shell alone. `demo:web` and the README's Web UI instructions ran only `build:web`, so on a checkout without a prior full build every plugin bundle 404s, the client loader marks every plugin failed, and the boot screen shows "Failed to load plugins". The frontend shell built fine, hiding the missing artifact behind a runtime browser failure.
## Decision
`demo:web` runs `npm run build` before `npm run build:web`, so the plugin `lib/client.js` bundles exist before `dsh web` serves them. The README's Web UI section runs `pnpm run build && pnpm run build:web` for the installed `~/.dsh/source` checkout, which the installer never builds.
## Verification
After the full build, all eight `/plugins/<id>/client.js` endpoints return 200 and a headless Chromium load of `http://127.0.0.1:3080` renders the shell with no "Failed to load plugins" state.
## Alternatives considered
**Build the bundles inside `dsh web` at startup.** The app runs from source via tsx and owns no build step; folding an artifact build into the server boot crosses the source/artifact separation and slows every launch.
**Widen the tsdown root config to emit client bundles from `pnpm run build:web`.** `build:web` is the Vite frontend build; the client bundles are a separate tsdown pass over `lib/types`. Merging the two conflates the shell build with the package build and still leaves the root `build` as the only producer.
## Consequences
`demo:web` now pays the full `tsc -b && tsdown` cost on every invocation instead of only the Vite build. That is the price of a runnable web demo from a clean tree; a caller who already built can invoke `dsh web` directly.
@@ -1,27 +0,0 @@
# Agent Note: demo:web 构建客户端插件的打包产物
Status: implemented
[English](2026-07-23-demo-web-builds-client-bundles.md) | 中文
## Problem
`dsh web` 通过 `GET /plugins/<id>/client.js` 提供每个 web 客户端插件的打包产物,其路径由包的 `exports["./client"]``lib/client.js`)解析得到。这些打包产物只由根目录的 `pnpm run build`(先 `tsc -b`,再执行各包的 `tsdown.client.ts` 配置)生成;Vite 的 `build:web` 步骤只构建前端外壳。`demo:web` 与 README 的 Web UI 说明只运行了 `build:web`,因此在未预先完整构建的检出上,每个插件的打包产物都返回 404,客户端 loader 将所有插件标记为失败,启动界面显示 "Failed to load plugins"。前端外壳能正常构建,把缺失的产物掩藏在浏览器运行时的失败背后。
## Decision
`demo:web``npm run build:web` 之前先运行 `npm run build`,使插件的 `lib/client.js` 打包产物在 `dsh web` 提供它们之前已经存在。README 的 Web UI 小节针对已安装的 `~/.dsh/source` 检出运行 `pnpm run build && pnpm run build:web`,因为安装器从不构建它。
## Verification
完整构建后,全部八个 `/plugins/<id>/client.js` 端点均返回 200,无头 Chromium 加载 `http://127.0.0.1:3080` 能渲染出外壳,不再出现 "Failed to load plugins" 状态。
## Alternatives considered
**在 `dsh web` 启动时构建打包产物。** 该应用通过 tsx 从源码运行,本身没有构建步骤;把产物构建塞进服务器启动流程会越过源码与产物的分离,并拖慢每次启动。
**扩大 tsdown 根配置,使 `pnpm run build:web` 也产出客户端打包产物。** `build:web` 是 Vite 前端构建;客户端打包产物是对 `lib/types` 的另一趟独立 tsdown 处理。把两者合并会混淆外壳构建与包构建,而且根目录的 `build` 仍是唯一的产出者。
## Consequences
`demo:web` 现在每次调用都要付出完整的 `tsc -b && tsdown` 代价,而不再只是 Vite 构建。这是从干净的代码树运行 web 演示所要付出的代价;已经完成构建的调用方可以直接调用 `dsh web`
@@ -1,6 +0,0 @@
# 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
2026-07-23-thinking-row-disclosure-target.md: f698c3cb0b73bf5c65b5d4b5b3f29de3080e0af6
2026-07-23-thinking-row-disclosure-target.zh.md: 0fba5c1d8f7beec7300dcd51e118a08d57d0e74f
@@ -1,29 +0,0 @@
# Agent Note: Thinking rows use one disclosure target
Status: implemented
English | [中文](2026-07-23-thinking-row-disclosure-target.zh.md)
## Problem
A collapsed reasoning entry presents `Think` and its one-line reasoning summary as one visual row, but an icon-only disclosure control leaves both visible labels inert. Applying title expansion to every tool row would instead break the generic tool-row contract, where the row opens details and only the leading control expands arguments.
## Decision
`ToolRow` exposes the opt-in `expandOnRowClick` policy. `ThinkRow` enables it so the title and reasoning summary form one accessible disclosure target; pointer clicks, Enter, and Space toggle the same component-local expanded state. Tool rows that do not opt in retain row-to-details selection and leading-control argument expansion.
## Verification
The component spec pins both Think click targets and the unchanged generic tool-row handoff. The keyless browser fixture loads the real sidebar and conversation bundles, opens an authored reasoning session, clicks the summary and title, and checks the disclosure state and expanded body.
## Alternatives considered
**Expand every tool row from its title.** Generic tool rows use row clicks for details selection, so sharing this behavior would conflate two controls.
**Keep icon-only disclosure.** The smallest hit target remains disconnected from the labels that describe the hidden content.
**Render separate title and summary buttons.** Two controls for one expanded state add duplicate focus stops and ambiguous semantics.
## Consequences
Thinking rows gain a larger pointer target and keyboard disclosure semantics without changing other tool interactions. The generic row component carries one optional policy because disclosure ownership differs between reasoning and tool calls.
@@ -1,29 +0,0 @@
# Agent Note: thinking 行使用单一展开目标
Status: implemented
[English](2026-07-23-thinking-row-disclosure-target.md) | 中文
## 问题
折叠的推理(reasoning)条目在同一视觉行中呈现 `Think` 和单行推理摘要,但仅图标可展开会让两个可见标签都无法交互。若让所有工具行均可通过标题展开,又会破坏通用工具行的契约:整行负责打开详情,只有前导控件负责展开参数。
## 决策
`ToolRow` 提供显式启用的 `expandOnRowClick` 策略。`ThinkRow` 启用该策略,让标题和推理摘要组成单一且无障碍的展开目标;鼠标点击、Enter 和 Space 都切换同一个组件本地展开状态。未启用该策略的工具行仍由整行完成详情选择,由前导控件展开参数。
## 验证
组件测试固定两个 Think 点击目标以及未改变的通用工具行交接行为。无密钥浏览器 fixture(测试前置数据)加载真实的侧边栏与会话 bundle,打开包含推理内容的既定会话,点击摘要与标题,并检查展开状态和展开后的正文。
## 考虑过的替代方案
**让每个工具行都可通过标题展开。** 通用工具行将整行点击用于详情选择,共享这一行为会混淆两个控件。
**保留仅图标展开。** 最小的点击目标仍与描述隐藏内容的标签脱节。
**把标题和摘要分别渲染为按钮。** 两个控件共享一个展开状态,会增加重复的焦点停靠点并产生含糊语义。
## 后果
thinking 行获得更大的鼠标点击目标和键盘展开语义,同时不改变其他工具交互。通用行组件承担一个可选策略,因为推理与工具调用的展开所有权不同。
@@ -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/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md
2026-07-27-question-composer-rows-do-not-shrink.md: 2e0e9b9ca6b141a200ba53d8b6f6f0cad5f7e89d
2026-07-27-question-composer-rows-do-not-shrink.zh.md: 845d9ee883ad1c2c7abc63a01fce626be924b87f
@@ -0,0 +1,50 @@
# Agent Note: Question-composer option rows are scroll content, not the slack absorber
Status: implemented
English | [中文](2026-07-27-question-composer-rows-do-not-shrink.zh.md)
## Problem
The question composer card is capped against the viewport (`max-height: min(60vh, 520px)`) and scrolls its option list, so the header and the footer actions stay reachable on long question batches. When the composer seat got short — a small window, or a short viewport with the details panel open — the option rows rendered on top of each other and on top of the question title.
The cap was not the defect; the distribution of the shortfall was. `.options` is a `flex-direction: column` box whose children default to `flex-shrink: 1`, so under-allocation shrank the rows first instead of overflowing the scroll container. A row shrank to its `min-height: 42px` while `.optionCopy` kept the taller intrinsic height its wrapped copy needs (two lines for an option with a description). With `align-items: center`, the copy is then centered on a box shorter than itself and paints outside the row's border box in both directions — over the title above and the next row below. Measured on the shipped client at 900x440: 6.5px of copy outside the row box, growing to 10px at 380px tall, while `.options` reported `scrollHeight === clientHeight` and therefore never offered a scrollbar.
Only rows whose copy wraps can reproduce it. A row whose copy fits on one line has slack between its content and its 42px minimum, so shrinking it stays invisible — which is why the pre-existing e2e fixture (options `Blue`/`Green`, no descriptions) rendered correctly at every size.
## Decision
`.option` and `.custom` declare `flex-shrink: 0`.
The rows are the scroll content of a capped card; the card's overflow belongs to `.options`, which already owns `overflow-y: auto` and `min-height: 0`. Pinning the children makes the shortfall reach that scroll container instead of being absorbed by the rows, which is the behavior the cap was designed for. The alternative — letting rows shrink but keeping the copy inside them — would require clipping or ellipsizing option descriptions at exactly the sizes where the user most needs to read them.
`.header` and `.footer` already carried `flex-shrink: 0` for the same reason at the card level; the option list's children were the missing half of that rule.
## Alternatives considered
**Clip or ellipsize the copy inside a shrunk row (`overflow: hidden` on `.option`).** This removes the overlap with one declaration and no layout rethink. Rejected because it trades a visible defect for a silent one: the row keeps its 42px, and the second line of an option description simply disappears at exactly the sizes where the card is tightest. The description is decision-relevant content, not decoration.
**Drop `align-items: center` for `align-items: flex-start`.** The copy would grow downward only, so it would no longer paint over the title above. It does not fix anything: a shrunk row still overflows onto the row below, and the fix would silently change the vertical alignment of every option row at every size, including the common one.
**Remove the card's `max-height` cap so nothing is ever squeezed.** No shortfall means no distribution problem. Rejected because the cap is what keeps the header and the footer actions on screen for a long question batch; removing it reintroduces the failure the cap exists to prevent (the composer seat is a fixed-height conversation column with `overflow: hidden`, so an uncapped card loses its own submit button instead).
**Cap the wrapped copy at one line (`white-space: nowrap` plus ellipsis on `.description`).** Rows would never wrap, so they could never overflow when shrunk. Rejected for the same reason as clipping, plus it degrades the wide-viewport rendering — where there is ample room — to fix a narrow-viewport defect.
## Consequences
- A squeezed composer scrolls its option list instead of overlapping it: at 900x380 the list reports `scrollHeight` 200 against `clientHeight` 114 and offers a scrollbar, where before it reported them equal and offered none.
- Option rows keep their full wrapped copy at every viewport size. Nothing is clipped or ellipsized, and the wide-viewport rendering is unchanged (the rule only takes effect when the flex box is under-allocated).
- The card now reaches its scroll state sooner, since the shortfall is no longer partly absorbed by the rows. That is the intended behavior of the cap, and it means a short seat shows a scrollbar in cases that previously showed a silently mis-painted list.
- The scenario's recorded question is longer than it needs to be for the round trip it primarily tests. That cost is deliberate: the layout invariant is unfalsifiable without wrapping copy, and a second fixture for one CSS rule would be worse.
## Verification
The web e2e composer scenario asserts the invariant on the live composer at three squeezed seat heights (900x520 / 440 / 380): every option row's children stay inside the row's border box. Two guards keep the assertion from holding vacuously — at least one row must be wrapped (the only shape that overflows) and `.options` must actually be scrolling (proof the seat is genuinely capped). The scenario's recorded question now carries long option descriptions for exactly that reason; without wrapping copy the assertion cannot fail.
Confirmed both directions against the built client: with `flex-shrink: 0` reverted the scenario fails (`scrolls: false`, 6.5px spill), and with it restored it passes. A standalone geometry sweep over 340 viewport sizes (420-1600 x 320-960) went from 86 sizes with copy outside a row box to zero.
The assertion is replay-only: record mode must reach the fixture write rather than aborting on layout. Note that the composer ships as a client-module bundle, so `pnpm run build:web` alone does not pick up a change to `QuestionComposer.module.css` — the package build must run for the browser lane to see it.
Reproducing the shortfall requires a short viewport, not a short container. The cap is `min(60vh, 520px)`, so shrinking the conversation column below the card's own height clips the card without under-allocating it — the rows keep their full height and nothing spills. Anything demonstrating or measuring this defect outside the e2e scenario has to change the viewport.
A stale `lib/` makes the browser lane assert against an older client than the tree, and a `pnpm run build` that fails part-way leaves exactly that: the packages built before the failure are current, the rest are not. Refreshing a golden in that state records the older client's surface. Confirm the build exited zero before capturing, and note that untracked directories under `packages/` are compiled too — a leftover from another branch can fail the build for reasons the diff does not explain.
@@ -0,0 +1,50 @@
# Agent Note: 提问 composer 的选项行是滚动内容,而非空间不足时的吸收方
Status: implemented
[English](2026-07-27-question-composer-rows-do-not-shrink.md) | 中文
## 问题
提问 composer 的卡片会按视口设上限(`max-height: min(60vh, 520px)`),并让选项列表自行滚动,这样在成批提问时标题和底部操作按钮始终可达。但当 composer 的容器变矮时(窗口较小,或视口偏矮且详情面板处于展开状态),选项行会互相叠在一起,也会叠到问题标题上。
缺陷不在这个高度上限,而在高度不足时由谁来吸收。`.options` 是一个 `flex-direction: column` 的盒子,其子元素默认取 `flex-shrink: 1`,因此空间不足时首先被压缩的是各个选项行,而不是让滚动容器产生溢出。一行会被压到它的 `min-height: 42px`,而 `.optionCopy` 仍保持文案折行后所需的更大固有高度(带描述的选项会占两行)。由于 `align-items: center`,文案于是以一个比自身更矮的盒子为基准居中,并向上下两个方向画到该行边框盒之外——向上盖住标题,向下盖住下一行。在实际发布的客户端上于 900x440 处实测:文案有 6.5px 落在行盒之外,视口高度降到 380px 时增至 10px,而 `.options` 报告的 `scrollHeight` 等于 `clientHeight`,因此始终不会给出滚动条。
只有文案会折行的选项行才能复现该问题。文案单行即可容纳的行,其内容与 42px 最小高度之间尚有余量,被压缩也看不出来——这正是既有 e2e fixture(测试前置数据)(选项为 `Blue``Green`,无描述)在任何尺寸下都渲染正常的原因。
## 决策
`.option``.custom` 声明 `flex-shrink: 0`
在设有高度上限的卡片中,这些行是滚动内容;卡片的溢出归 `.options` 承担,它本就持有 `overflow-y: auto``min-height: 0`。把子元素固定住之后,高度不足会传导到那个滚动容器,而不再被行本身吸收,这正是该高度上限设计时想要的行为。另一种做法是允许行被压缩,但把文案约束在行内,那就必须在用户最需要阅读选项描述的尺寸上对其做裁剪或省略号处理。
`.header``.footer` 出于同样的原因,已在卡片层级带有 `flex-shrink: 0`;选项列表的子元素正是这条规则缺失的另一半。
## 曾考虑的替代方案
**在被压缩的行内裁剪文案或加省略号(对 `.option` 设 `overflow: hidden`)。** 这样一条声明就能消除重叠,且不必重新考虑布局。之所以否决:它把一个可见缺陷换成了一个无声缺陷——行仍保持 42px,而选项描述的第二行会在卡片最紧张的那些尺寸上直接消失。描述属于影响决策的内容,不是装饰。
**把 `align-items: center` 改为 `align-items: flex-start`。** 文案就只会向下生长,因此不再向上盖住标题。但这什么也没修好:被压缩的行依然会溢出到下一行上,而且这一改动会在所有尺寸下(包括常见尺寸)无声改变每个选项行的垂直对齐。
**移除卡片的 `max-height` 上限,使其永远不会被压缩。** 没有高度不足,就没有分配问题。之所以否决:正是这个上限保证成批提问时标题和底部操作按钮留在屏幕内;移除它会重新引入该上限本就为之存在的失败(composer 所处的容器是一个固定高度、`overflow: hidden` 的会话列,因此不设上限的卡片会连自己的提交按钮一起丢掉)。
**把折行文案限制为单行(对 `.description` 设 `white-space: nowrap` 加省略号)。** 行永远不会折行,因此被压缩时也永远不会溢出。否决理由与裁剪相同,此外它还为了修一个窄视口缺陷,而牺牲了空间充裕的宽视口渲染效果。
## 后果
- 被压缩的 composer 会滚动其选项列表,而不是让它互相重叠:在 900x380 处,该列表报告 `scrollHeight` 为 200、`clientHeight` 为 114,并给出滚动条;此前两者相等,不给滚动条。
- 选项行在任何视口尺寸下都保留完整的折行文案。不裁剪、不加省略号,宽视口下的渲染保持不变(该规则仅在 flex 盒子空间不足时才生效)。
- 由于高度不足不再被行部分吸收,卡片现在更早进入滚动状态。这正是该高度上限想要的行为,也意味着在此前只会无声画错列表的情形下,矮容器现在会显示滚动条。
- 该场景录制的问题,比它主要测试的那次往返所需的长度更长。这个代价是有意付出的:没有折行文案,该布局不变式无法被证伪,而为一条 CSS 规则再加一份 fixture 会更糟。
## 验证
Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440380)上,对活动的 composer 断言该运行时不变式:每个选项行的子元素都留在该行的边框盒之内。两道守卫防止该断言空洞地成立——必须至少有一行处于折行状态(这是唯一会溢出的形态),且 `.options` 必须确实处在滚动状态(证明容器确实触及了高度上限)。该场景录制的问题现在带有较长的选项描述,正是为此;没有折行文案,这条断言不可能失败。
在构建产物客户端上双向确认过:撤销 `flex-shrink: 0` 后该场景失败(`scrolls: false`,6.5px 溢出),恢复后通过。一次覆盖 340 种视口尺寸(420-1600 x 320-960)的独立几何遍历,从 86 种尺寸存在文案落在行盒之外,降到 0 种。
该断言仅在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。另需注意,composer 以客户端模组包的形式发布,因此单跑 `pnpm run build:web` 不会带上对 `QuestionComposer.module.css` 的改动——必须执行包构建,浏览器测试通道才能看到它。
要复现这种空间不足,需要的是矮视口,而不是矮容器。高度上限为 `min(60vh, 520px)`,因此把会话列压到比卡片自身高度更矮,只会裁剪卡片,而不会让它空间不足——各行仍保持完整高度,也不会有任何溢出。凡是在 e2e 场景之外演示或测量该缺陷的手段,都必须改变视口。
`lib/` 陈旧会让浏览器测试通道对着一个比工作树更旧的客户端做断言,而中途失败的 `pnpm run build` 留下的正是这种状态:失败之前构建的那些包是新的,其余不是。在这种状态下刷新 golden,记录下来的是旧客户端的界面。抓取之前先确认构建以 0 退出;另需注意 `packages/` 下的未跟踪目录同样会被编译——来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。