diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index 0d6a23bebe..8193e5e839 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: 83d47e3a7d91bbcd2ceaf7b11cf13316142eb3ed -2026-06-21-bounded-llm-request-recovery.zh.md: 00dcbad3d1023ad33a22297bfe938b94bce839d4 +2026-06-21-bounded-llm-request-recovery.md: 5c76ed5d754ea40f41dff78cb56ee7fc139a32b1 +2026-06-21-bounded-llm-request-recovery.zh.md: 1fa56f3fe0405cab663c2843d423a78d910170dd diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 83d47e3a7d..5c76ed5d75 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -60,11 +60,11 @@ For an eligible failure with budget remaining, the one-based transient retry cou The plugin owns a lifetime `AbortController` and tracks every active recovery callback, including delegated waterfall work and backoff. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; abort wins over a late delegated retry decision, and a captured callback can neither retry nor enter the rest of its waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. -Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, complete resolved-policy key, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The key sorts the code set and separates retry histories when a provider route is replaced by a behaviorally different same-mode policy. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. +Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, complete resolved-policy key, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The key sorts the code set and separates retry histories when a provider route is replaced by a behaviorally different same-mode policy. The plugin owns the `SessionEventMap` augmentation and exports the payload through its browser-safe `./types` subpath; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships with production renderers and replay/snapshot coverage, because its purpose is operational state rather than trace collection. The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. For an owned failure it records and awaits the delay, then returns `{ kind: 'retry' }` without delegating. Turn cancellation and plugin disposal end the wait without returning a retry; the loop's cancellation/disposal checks remain authoritative. -The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same provider-routed policy. Library consumers retain explicit plugin composition: omitting the plugin leaves request failures terminal. +The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, ACP, and headless example compositions use the same provider-routed policy. The shipped Web composition also loads it, so browser and command-line requests use the same provider defaults. Library consumers retain explicit plugin composition: omitting the plugin leaves request failures terminal. ### Make one layer own visible attempts @@ -82,7 +82,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada ### Keep attempts separate in the existing log -A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure; message derivation continues to ignore the failed chunks. +A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects consecutive retry-turn events into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from subsequent turn facts. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows. If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added. @@ -116,7 +116,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. - Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. -- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus scheduled-retry rendering. Keyless snapshots cover scheduling, cancellation, success, and exhaustion; ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. +- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, a real Web composition test covers partial transport failure through recovery, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. - Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 00dcbad3d1..1fa56f3fe0 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -60,11 +60,11 @@ agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误 插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的恢复回调,包括委托的 waterfall(瀑布式事件)工作与退避。effect 清理会先注销监听器,再中止并等待活跃回调;中止会胜过较晚到达的委托重试决策,被捕获的回调在插件释放后既不能重试,也不能进入其 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。 -休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、提供方、策略 mode、完整的解析策略 key、提供方策略重试编号、该 mode 存在时的有限上限、计划延迟和 `LlmFailure`。该 key 会对 code 集排序,并在提供方路由被行为不同但 mode 相同的策略替换时分隔重试历史。该插件拥有 `SessionEventMap` 声明合并;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它仅与生产渲染器及回放/快照覆盖一起交付。 +休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、提供方、策略 mode、完整的解析策略 key、提供方策略重试编号、该 mode 存在时的有限上限、计划延迟和 `LlmFailure`。该 key 会对 code 集排序,并在提供方路由被行为不同但 mode 相同的策略替换时分隔重试历史。该插件拥有 `SessionEventMap` 声明合并,并通过其浏览器安全的 `./types` 子路径导出载荷;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它会与生产渲染器及回放/快照覆盖一起交付。 对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。对自身处理的失败,它会记录并等待延迟,然后在不委托的情况下返回 `{ kind: 'retry' }`。轮次取消和插件释放会结束等待且不返回重试动作,此后仍以循环的取消/释放检查为准。 -agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一套按提供方路由的策略。库消费方仍需显式组合插件:省略该插件时,请求失败保持终态。 +agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)、ACP(Agent Client Protocol)和 headless 示例组合使用同一套按提供方路由的策略。随产品交付的 Web 组合也会加载该插件,因此浏览器请求与命令行请求使用相同的提供方默认值。库消费方仍需显式组合插件:省略该插件时,请求失败保持终态。 ### 由单一层负责可见的尝试 @@ -82,7 +82,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 在现有日志中分隔尝试 -一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。 +一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷契约,在 `llm/retry` 到达时清除失败的部分输出,将连续重试轮次的事件投影为稳定的一行,并用最新一次尝试更新该行,再从后续轮次事实派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。 如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。 @@ -116,7 +116,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。 - 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新轮次中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。 - 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。 -- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 撤回和计划重试渲染。无密钥快照覆盖调度、取消、成功和耗尽;ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 +- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 - 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。 - `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index 70618612a2..e603129715 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: b9718da4725316c64686adef24827e2984d8723d -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 2add148054e8f97c65600cd719fb4f8e0283f52d +2026-07-19-gui-layering-and-rpc-protocol.md: b7081591cf7e5e3c586c74c5a71b4317376135cb +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 89557182ca7781f4fb59b8daf866aaca96cf20ee diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index b9718da472..b7081591cf 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -204,7 +204,7 @@ The same domain tree as `ApiProxy`, but unary methods **take the business payloa | `callUnary` | mint → tap → POST full form → `serverResponseSchema` parse → **rpcId echo check** (mismatch throws) → tap → emit narrow form | | `readSse` | streaming fetch (not EventSource), `\n\n` framing, `data:` concatenation, ServerRequest full-form parse, tap, emit narrow `RpcRequest` | | `respond` | client-response passthrough (rpcId is an echo — never minted here); response body parsed by `rpcReceiptSchema` | -| unary timeout | `AbortSignal.timeout` (default 30s, constructor-tunable); streams have no timeout (long-lived by nature) | +| unary deadline | Ordinary unary calls use `AbortSignal.timeout` (default 30s, constructor-tunable); user-paced `host.pickDirectory` and `command.execute` omit that deadline but keep caller/connection cancellation; streams have no deadline | | `resolveBase` | browser = same-origin origin; no-location environment (Node) = the `http://dsh.internal` fake authority | ### The instance-level envelope observation aspect @@ -234,7 +234,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation ## Consequences -Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. The accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. +Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives. ## Alternatives considered @@ -252,3 +252,4 @@ Every client shape consumes one contract: adding a unary method is a five-step m | A DTO layer (a second wire-only structure set) | Core types reach the browser type-only at zero cost; a DTO is a permanent two-way synchronization tax | | Cursor resumption (implementing mux since) | Reconnect = rebuild (opencode-style) covers all v1 needs; the signature keeps the seat, implementation waits for a real consumer | | A createApiClient factory function (the original implementation) | Platform differences (transport/observation) are inheritance aspects, not parameters; the class family lets the fixture substitute at the protocol layer instead of wrapping a fake envelope | +| Applying the 30-second transport deadline to `command.execute` | Command duration is operation work, not a transport-health budget; the deadline kills valid long-running handlers, while caller/connection cancellation already supplies the required stop path | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 2add148054..89557182ca 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -202,7 +202,7 @@ export type ResponseValue = | `callUnary` | mint → tap → POST 全形 → `serverResponseSchema` parse → **rpcId 回显校验**(不符即 throw)→ tap → 吐窄形 | | `readSse` | streaming fetch(非 EventSource)、`\n\n` 分帧、`data:` 拼接、ServerRequest 全形 parse、tap、吐窄形 `RpcRequest<帧>` | | `respond` | client-response 透传(rpcId 是回填,此处不 mint);应答体 `rpcReceiptSchema` parse | -| unary 超时 | `AbortSignal.timeout`(默认 30s,构造参数可调);流不设超时(长连接本性) | +| unary 时限 | 普通 unary 调用使用 `AbortSignal.timeout`(默认 30s,构造参数可调);由用户掌控节奏的 `host.pickDirectory` 和 `command.execute` 不设该时限,但保留调用方/连接取消;流不设时限 | | `resolveBase` | 浏览器=同源 origin;无 location 环境(Node)=`http://dsh.internal` 假 authority | ### 实例级 envelope 观测切面 @@ -232,7 +232,7 @@ export type ResponseValue = ## Consequences -所有 client 形态消费同一契约:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。接受的代价:两组包需要显式 tsconfig paths 条目;预留接缝(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。 +所有 client 形态消费同一契约:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留接缝(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。 ## Alternatives considered @@ -250,3 +250,4 @@ export type ResponseValue = | DTO 层(wire 专用第二套结构) | core 类型 type-only 直达浏览器零成本;DTO 是永久的双向同步税 | | cursor 续传(mux since 实装) | 重连=重建(opencode 同款)覆盖 v1 全部需求;签名留座,实装等真实消费者 | | createApiClient 工厂函数(原实现) | 平台差异(传输/观测)是继承切面不是参数;类体系让 fixture 在协议层替换而不是包一层假信封 | +| 对 `command.execute` 应用 30 秒传输时限 | 命令耗时属于操作本身,而非传输健康预算;该时限会终止本应继续运行的长时处理器,调用方/连接取消已提供所需的停止路径 | diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index f140786632..451dcd1fca 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: b0873b7aa7bccd3d613f3113fa18207770952e5d -2026-07-23-client-plugin-loading-model.zh.md: f3472dbfc5a78924e77337bf92ce5983c8492c4c +2026-07-23-client-plugin-loading-model.md: 02347f2964942b89ec1f0a6ec483f4c2b2f9e68c +2026-07-23-client-plugin-loading-model.zh.md: ea927d35860fbbba567c47cea0ee3a45133ce0f4 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index b0873b7aa7..02347f2964 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -56,8 +56,8 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the **Host side — compose the graph.** -1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`. -2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports). +1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)). +2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber. 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins//client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself). Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index f3472dbfc5..ea927d3586 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -56,8 +56,8 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点 **host 侧——组合这张图。** -1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册行 import 失败由 boot 的 `assertEntriesLoaded` 捕获。 -2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败(FAILED fiber,由 sweep 上报)。 +1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。 +2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都经 fetch 供给:`/plugins//client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index aa927a2e7d..d50428d5ed 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: a2080024d36d54162f4f4aa79896e51efd708f59 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 1c430bf2939f9f556f378bdeb78872a0091a592d +2026-07-24-web-config-tree-boot-and-transport-layering.md: 88f94b1f58ae7a3451c7772f4a9ff7d6564254c0 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: ea2a8f70a6c2d4207d4388a9303fbc6ce6e94238 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index a2080024d3..88f94b1f58 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,9 +12,9 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). +**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. -**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. +**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. **Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 1c430bf293..ea2a8f70a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 +**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 -**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 +**boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 **每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json(`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model` → `api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 98423a8c7d..09ab4376b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 92bb91c3e892d928cedf18ec57c725a116b6ffc8 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 5bee6df52f16d935aa4f4ccff8627a2d43d44c8c +2026-07-25-web-input-machine-and-slash-pipeline.md: c3deadb34d3a633525dde701c92bcc98c05e5d6e +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7a6988423dcdffebb0a28735146439c8ade0a862 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 92bb91c3e8..c3deadb34d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -63,7 +63,7 @@ Calls that stay un-evented (registry registration → explicit call → await): A trigger/menu/pick pipeline with zero knowledge of "commands": - The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects). -- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); a `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. +- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's textarea selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller. - Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core. ### hub / facade: the resident shell and the strict-session input body @@ -101,7 +101,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. - `conversation.composer.dock` — the stats band on the composer's top edge. - `conversation.input.left` / `conversation.input.right` — the tool-row left and right regions. -- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. +- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. The plan seat stays empty while inactive because the shared Command source owns entry; an effective plan target renders the warn-state `Plan ×` status button, whose only action is `/plan off`. - `conversation.hero.workspace` (root scope) — the Workspace picker shared by the no-session and blank Hero; a pick reuses or creates the target blank session through `connectWorkspace`, moving the draft where necessary before switching current. ### Testing discipline @@ -122,6 +122,8 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only | | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning | | A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth | +| An always-visible Plan on/off toggle | The shared Command source already owns entry; a second entry point turns a status seat into redundant mode chrome | +| A second plus-menu component/controller, or an Add/File group above Command | It would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability | | All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity | ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 5bee6df52f..7a6988423d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -63,7 +63,7 @@ occurrence 表与 chip 三投影: 对"命令"零知识的触发/菜单/pick 管线: - service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`;(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。 -- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行)、pick 编排(outcome → 自派 bail 事件);`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 +- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)。`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的 textarea selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个 session scope 出生时对 source roster 做一次 `warm(projection)`,projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。 - 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain:`/` 到处 + `@` 行内 / claimed:`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。 ### hub / facade:常驻外壳与严格 session 输入体 @@ -101,7 +101,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 - `conversation.composer.dock`——composer 上沿统计带。 - `conversation.input.left` / `conversation.input.right`——工具行左右区。 -- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。 +- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。plan seat 未激活时保持为空,因为入口归共享 Command source 所有;有效 plan 目标会渲染 warn 状态的 `Plan ×` 状态按钮,其唯一动作是 `/plan off`。 - `conversation.hero.workspace`(root scope)——无 session / blank Hero 共用的 Workspace picker;pick 经 `connectWorkspace` 复用或创建目标 blank session,必要时搬运 draft 后切 current。 ### 测试纪律 @@ -122,6 +122,8 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 | | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 | | 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 | +| 始终可见的 Plan 开/关切换 | 入口已归共享 Command source 所有;第二个入口会把状态 seat 变成冗余的 mode chrome | +| 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 | | 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 | ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml index fa54d657a9..3c04e9b255 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md -2026-07-30-settings-write-path-integrity.md: 07bd095162879c8e7866846cf562f6a13307e5fc -2026-07-30-settings-write-path-integrity.zh.md: 5d02177073d482b61750d7bdfbbd0866bc227a6a +2026-07-30-settings-write-path-integrity.md: 7bd50adc8a812759c3ae3f80a50978d75baa712a +2026-07-30-settings-write-path-integrity.zh.md: da07745ef1de1b694fc3fd1ee3d04322cdadc992 diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md index 07bd095162..7bd50adc8a 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.md @@ -14,7 +14,7 @@ Review found the provider's write path could destroy state it never observed, an **One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active. -**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff, a 2 s acquisition deadline, and stale takeover after 5 s (a crashed holder, broken with a warning). Readers never lock — the rename commit is atomic — so contention is writer-only and resolves in milliseconds. The lock constants are protocol invariants, not config: a holder rewrites one small document, so the deadline and stale age derive from that bound, not from deployment taste. +**Writes hold a `wx`-created `.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config. **Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is. @@ -24,7 +24,7 @@ Review found the provider's write path could destroy state it never observed, an ## Alternatives considered -- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is ~40 lines with deterministic tests (including injected `EEXIST`/`stat` races). The policy favors dependencies that delete owned code; this one would replace 40 explained lines with an opaque peer. +- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its ownership and retry policy is broader than this one-file protocol needs, and the shipped lock is a small exclusive-create loop with deterministic contention tests. The policy favors dependencies that delete owned code; this one would replace a narrow protocol with an opaque peer. - **Revision/CAS instead of a lock** — rename cannot express compare-and-swap, so a CAS needs a version sidecar or content re-hash and a retry loop in every writer; the lock achieves the same serialization with one primitive and keeps readers free. - **Merging external edits into the in-flight write's own section** — the seam merges patches over the state visible at call time, so a same-namespace external edit racing a write still resolves last-write-wins; folding it in would need three-way merge semantics no consumer has asked for. The write publishes the external state first, so the loser is at least observed before being superseded. - **Declaring async `settings/updated` listeners unsupported** — the typed signature is `void` and lint flags misused promises, but an unlinted JS plugin can still register an async listener; a contract note cannot un-throw an unhandled rejection, so containment is the only defense that holds at runtime. @@ -32,4 +32,4 @@ Review found the provider's write path could destroy state it never observed, an ## Consequences -`update()` gained a documented failure mode (lock deadline, invalid on-disk document) and the rejection messages carry `$`-rooted paths. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up. +`update()` has documented failure modes for the lock deadline and an invalid on-disk document, and rejection messages carry `$`-rooted paths. A crashed holder can leave a lock that requires verified operator removal; automatic age-based takeover would permit overlapping writers. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up. diff --git a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md index 5d02177073..da07745ef1 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-settings-write-path-integrity.zh.md @@ -18,7 +18,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 **单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。 -**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行:指数退避、2 s 获取截止时间、5 s 后陈旧接管(持有者已崩溃;打破旧锁时给出告警)。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间,毫秒级即可化解。锁的各项常量是协议不变式,不是配置:持有者只是重写一份小文档,截止时间与陈旧时限都从这一上界推得,而非出自部署偏好。 +**写入持有以 `wx` 创建的同目录 `.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避与 2 s 获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置。 **观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件契约现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。 @@ -28,7 +28,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 ## 曾考虑的替代方案 -- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁约 40 行并带确定性测试(含注入的 `EEXIST`/`stat` 竞态)。该政策偏向能删除自有代码的依赖;这个依赖只会把 40 行带解释的代码换成一个不透明的等价物。 +- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其所有权与重试策略比这个单文件协议所需的更宽泛,而已交付的锁只是一个小型独占创建循环,带确定性的竞争测试。该政策偏向能删除自有代码的依赖;这个依赖只会把一个窄协议换成不透明的等价物。 - **用修订号/CAS 取代锁**——rename 表达不了 compare-and-swap,因此 CAS 需要一个版本伴随文件或内容重哈希,外加每个写方里的一个重试循环;锁用一个原语实现同样的串行化,还让读方完全免锁。 - **把外部编辑合并进正在进行的写入自身的分节**——seam 是在调用时刻可见的状态之上合并 patch 的,因此与写入竞态的同 namespace 外部编辑仍按后写胜出解决;要把外部编辑并进来,需要三方合并语义,而没有任何消费方提出过这种需求。写入会先发布外部状态,落败一方至少在被取代之前被观察到。 - **宣布不支持异步 `settings/updated` 监听器**——类型签名是 `void`,lint 也会标记误用的 promise,但未经 lint 的 JS 插件仍能注册异步监听器;契约里的一句说明无法收回已经抛出的 unhandled rejection,收容是唯一在运行时守得住的防线。 @@ -36,6 +36,6 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删 ## 后果 -`update()` 有了成文的失败模式(锁截止时间到期、磁盘文档非法),rejection 消息携带以 `$` 为根的路径。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。 +`update()` 对锁获取期限与磁盘文档非法都有成文的失败模式,rejection 消息携带以 `$` 为根的路径。持有者崩溃后可能留下锁,需要操作者核实后移除;若按锁龄自动接管,则会允许多个写入方重叠。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。 [用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包(package)的那些 PR(Pull Request)所有,向上合并时按本模板处理。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml new file mode 100644 index 0000000000..0aed75f807 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-search.md +2026-07-27-web-session-search.md: 9a634c586a4793d1c6986a7e7c0b0c1157b5b687 +2026-07-27-web-session-search.zh.md: 5ec2baf7443aaaaa75abc348ee426df9c14fbaa2 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md new file mode 100644 index 0000000000..9a634c586a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -0,0 +1,44 @@ +# Agent Note: Web past-session search + +Status: implemented + +English | [中文](2026-07-27-web-session-search.zh.md) + +## Problem + +The Web sidebar exposes session titles and Workspace membership but cannot retrieve a past conversation from words that appear only inside its messages. Scanning histories in the browser would require attaching or loading every session, duplicate the existing indexed-search service, and make cold persisted sessions both slow and easy to omit. The product also needs a predictable failure path: an unavailable derived index must not erase title matches that the client can compute locally. + +## Decision + +The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md) with `openAt: first-search` and an in-memory database. The service is ACTIVE at boot, while its `node:sqlite` module and connection-private handle open only on the first content query. This keeps Node 22 startup output free of SQLite's experimental warning before search is used without promising to suppress the warning when search first imports the module. Each service instance owns its index, preserving the SQLite backend's single-owner contract across parallel CLI or Web invocations without leaving process-scoped derived files behind. The database starts empty and lazily reconciles live and persisted sessions on that first query. It remains a disposable derived index, separate from canonical JSONL persistence. + +The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message`, `assistant/message`, and `steering/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. The first provider page requests 20 hits; a first-page `SESSION_QUERY_INVALID_LIMIT` halves that size through 10, 5, 2, and 1, retaining the learned size across continuations and stale-generation restarts. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Emitted snippets contain at most 240 Unicode code points; the Host and wire schema share the protocol bounds and code-point-safe truncation helper, while the wire schema independently enforces the snippet bound at client parse. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. A stale continuation discards the current attempt's partial results, deduplication entries, and cursors, then restarts from the first page against the original visibility snapshot. Limit probes and stale retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider call, and wins over a concurrent limit or stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store. + +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. + +The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives beside the response schema that enforces it in `dsh-host-apiproxy`, and `SessionsService.searchResultLimit` re-exposes that constant for presentation plugins. Reaching it from a feature is an explicit widening of the sessions domain: `ISessions` — the face injected as `ctx.sessions`, and therefore what the test runtime's sessions double must implement — declares the search verb next to that bound. The connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound that the schema's fixed `max` forbids, and would leave the same fact with two homes in the same module. + +Content matching inherits the SQLite backend's normalized literal token/phrase semantics. The shared semantic projection excludes reasoning blocks, so UI search never returns a model's private reasoning as a hit or snippet; the derived-index schema version advances so existing persistent indexes rebuild without the former documents. FTS5 operators are inert data, and this surface adds no typo, fuzzy, prefix, or arbitrary-substring expansion. In particular, the `unicode61` tokenizer may treat an uninterrupted Chinese sequence as one token, so a shorter query such as `搜索` is not guaranteed to match inside `会话搜索功能`. Title and Workspace matching remains ordinary client-side substring matching. + +## Failure and visibility contract + +Search never widens session visibility: cold sessions without a servable cwd are absent for the same reason they are absent from `session.list`, and only provider hits whose ids occur in that baseline can leave the Host. Shadowed and log-only events, tool events outside message content, errors, todos, and other trace records do not produce UI hits. + +While the first or a later content request is pending, the UI keeps immediate metadata matches and shows a history-search status. If the backend fails, the same rows remain and a warning explains that content search is unavailable. Zero merged rows produce an explicit empty state. More than 20 candidate rows produce a refine-query hint. + +## Alternatives considered + +- **Scan every session history in the browser** — rejected because it attaches transport and fold cost to the UI, misses cold logs unless they are loaded, and duplicates the semantic extraction and source reconciliation already owned by `ctx.sessionQuery`. +- **Make trigram or fuzzy search part of the first release** — rejected because it changes index size, ranking, short-query behavior, and product expectations. Trigrams also do not by themselves solve two-character queries. The first release uses the existing backend contract and leaves recall expansion as a separate measured decision. +- **Return event addresses and jump to the exact match** — rejected for this release because conversation virtualization and stable event navigation need a separate UI contract. Session-level navigation is useful without coupling search to that work. +- **Expose cursor pagination in the sidebar** — rejected in favor of a fixed top-20 surface and a narrow-query hint; this keeps the interaction and cancellation state bounded. + +## Consequences + +Past persisted conversations become discoverable without opening them first, while the host retains one visibility boundary and one semantic-index implementation. Immediate local results hide most request latency, cancellation prevents obsolete queries from repainting the list, and backend failure degrades to the behavior available before content search. + +The first content query can take longer because it imports and opens SQLite before paying lazy reconciliation. Search quality is token/phrase recall rather than fuzzy or arbitrary substring recall, including the documented continuous-Chinese limitation. Results are session-level, capped at 20, and have no paging or exact-message navigation. A valid but pathologically unselective or repeatedly stale provider attempt that does not complete within 100 calls takes the metadata-only failure path instead of consuming unbounded work. + +## Testing + +Host tests pin request and response validation, visible-session filtering, event/surface filters, result and snippet bounds, adaptive provider limits inside the shared call budget, learned-limit stale restarts, cursor and cross-page deduplication behavior, cancellation precedence, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; semantic extraction and SQLite/fixture search tests pin exclusion of reasoning-only text. The Node 22 compatibility gate builds the CLI and Web artifacts, boots the shipped `dsh web`/`AppCLIEntry` composition under plain Node with ambient warning suppression removed and an isolated temporary home/provider environment, waits for settled startup, and disposes it through the shipped signal path. Fixture, runtime, and UI tests pin match-centered bounded snippets, stateless delegation, the 500-code-unit query boundary, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, English copy, ARIA tree membership, row rendering, and navigation semantics. A keyless assembled Web test preserves the lazy-open config while seeding an unopened persisted conversation, finds it by visible message content through the SQLite index, captures the sidebar result, opens it, and verifies that the query remains. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md new file mode 100644 index 0000000000..5ec2baf744 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -0,0 +1,44 @@ +# Agent Note: Web 历史会话搜索 + +Status: implemented + +[English](2026-07-27-web-session-search.md) | 中文 + +## 问题 + +Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只出现在消息中的词语检索历史对话。在浏览器中扫描历史记录,需要附加或加载每个会话,重复实现现有的索引搜索服务,也会让冷态持久化会话的检索既缓慢又容易遗漏。产品还需要一条可预测的故障路径:派生索引不可用时,不得抹去客户端能够在本地计算出的标题匹配结果。 + +## 决策 + +Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。服务启动时处于 ACTIVE 状态,而其 `node:sqlite` 模块与连接私有句柄分别要到首次内容查询才会导入和打开。这让 Node 22 的启动输出在使用搜索前不会出现 SQLite 实验性警告,但并不承诺在首次搜索导入该模块时抑制警告。每个服务实例都独占自己的索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动,并在该首次查询时惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。 + +宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message`、`assistant/message` 和 `steering/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。发出的 snippet 最多包含 240 个 Unicode 码点;宿主与传输 schema 共用协议边界及码点安全的截断辅助函数,而传输 schema 会在客户端解析时独立强制执行 snippet 上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 + +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 + +结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 位于 `dsh-host-apiproxy` 中强制执行它的响应 schema 旁边,`SessionsService.searchResultLimit` 则把该常量重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明了搜索动作。连接 handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,而 schema 固定的 `max` 恰恰禁止这一点,并且会让同一事实在同一模块内拥有两处归属。 + +内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。共享语义投影会排除推理(reasoning)块,因此 UI 搜索绝不会将模型的私有推理作为命中或 snippet 返回;派生索引的 schema 版本会随之前进,使现有持久化索引重建并移除先前的这些文档。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。 + +## 故障与可见性契约 + +搜索绝不会扩大会话可见范围:没有可供服务的 cwd 的冷会话会被排除,原因与它们不出现在 `session.list` 中相同;只有 id 位于这条基线中的提供方命中才能离开宿主。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。 + +首个或后续内容请求仍在处理期间,UI 会保留即时元数据匹配结果,并显示历史搜索状态。如果后端失败,这些行会保持不变,并显示警告说明内容搜索不可用。合并后没有任何行时,界面会显示明确的空状态。候选行超过 20 条时,界面会提示用户缩小查询范围。 + +## 曾考虑的替代方案 + +- **在浏览器中扫描每个会话的历史记录**:不予采纳,因为这会让 UI 承担传输与折叠开销;除非加载冷态日志,否则还会漏掉这些日志;并会重复实现已经由 `ctx.sessionQuery` 负责的语义提取与源对齐。 +- **首版即加入 trigram 或模糊搜索**:不予采纳,因为这会改变索引大小、排序、短查询行为与产品预期。trigram 本身也无法解决双字查询。首版沿用现有后端契约,将召回扩展留作另一项基于度量结果的决策。 +- **返回事件地址并跳转至确切匹配位置**:本版不予采纳,因为对话虚拟化与稳定的事件导航需要单独的 UI 契约。会话级导航本身已有价值,无需让搜索与这项工作耦合。 +- **在侧边栏公开游标分页**:不予采纳,改为固定显示前 20 条结果并提示缩小查询范围;这样可使交互与取消状态保持有界。 + +## 后果 + +无需预先打开,即可检索到历史持久化对话,同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟,取消机制可防止已作废查询重新渲染列表,后端故障则会降级为内容搜索尚不可用时已有的行为。 + +首次内容查询可能耗时更长,因为它要先导入并打开 SQLite,再承担惰性对齐的开销。搜索质量采用 token/短语召回,而不是模糊召回或任意子串召回,并受上述连续中文限制。结果粒度为会话,最多 20 条,不支持分页,也不能跳转到具体消息。如果有效但选择性极差或反复陈旧的提供方尝试未能在 100 次调用内完成,系统会进入仅保留元数据匹配的故障路径,而不是无限制地继续处理。 + +## 测试 + +宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为契约。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为契约;语义提取测试与 SQLite/fixture 搜索测试将排除仅存在于推理中的文本固定为契约。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml new file mode 100644 index 0000000000..f9b2cfa908 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-diff-card.md +2026-07-30-web-diff-card.md: 396bdbc2843c1bbed5c6a913be436d8b9e96a81c +2026-07-30-web-diff-card.zh.md: afdeafa6e94b46b4f0fbd4a065afdac8a93ac57d diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md new file mode 100644 index 0000000000..396bdbc284 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -0,0 +1,57 @@ +# Agent Note: Web diff card — the write/edit render intent reaches the browser + +Status: implemented + +English | [中文](2026-07-30-web-diff-card.zh.md) + +## Problem + +The `write` and `edit` tools declare `card: 'diff'` for both their call and their result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the intended change derived from the arguments, and the result view carries the applied contextual hunks (`FileDiff[]`, computed by `packages/fs/tool-fs/src/diff.ts` and persisted in the result `meta` so replay reproduces it). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the TUI already renders it as per-file `+`/`-` blocks with a `+A -R · N file(s)` footer. + +The Web client ignored it. A write/edit call landed on `GenericToolCard`, whose row is derived from raw tool args, and the details panel flattened the result's content blocks into one `
`. The `diffs` payload — the whole point of the result — was discarded, so a file mutation read as a one-line confirmation with no visible change.
+
+This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` arm: that change made the Web client a consumer of the `terminal` render intent; this one makes it a consumer of the `diff` render intent, reusing the same four-layer shape.
+
+## Decision
+
+`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
+
+The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends:
+
+- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both.
+- **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side.
+- **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends.
+- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry.
+- **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable.
+
+Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer.
+
+The chat row renders the diff resident under its path-link summary, capped at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 — the same inline-output decision and the same in-flow-vs-reading-surface split recorded for the [terminal card](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention). A write/edit row is single-file, so its summary stays an openable path link AND its diff card expands; the two coexist because the card is not the path's args body.
+
+## Alternatives considered
+
+**A side-by-side (two-column) diff.** Rejected for now by the owner: it is denser but does not fit the narrow chat row, and the goal was parity with the TUI's single-column unified form. A two-column mode in the details panel is a later props change, not a redesign.
+
+**Git-style line-number gutters.** The `FileDiff` contract carries only `{ path, oldText, newText }` — `structuredPatch`'s hunk start lines are dropped in `diff.ts`, so no line number reaches the client. Rendering a numbered gutter needs a backend contract change (carry `oldStart`/`newStart`) and a matching TUI upgrade to stay consistent; deferred so this PR stays a pure Web consumer of the existing contract.
+
+**Reuse `CodeBlock`.** Rejected for the same reason the terminal card was: `CodeBlock` soft-wraps and has no per-line `+`/`-` role, no path headers, and no footer. The two share geometry and font tokens, which is the only part where one implementation is correct for both.
+
+## Consequences
+
+`DiffBlock` reads only the diff view's fields, so it stays a pure function of what the render intent carries — replay-safe like the presenters that produce the view. A UI without the diff capability still gets the bridge's generic fallback; nothing about the tool's result shape changed. No new runtime dependency: unlike the terminal card's `anser`, a diff needs no parser.
+
+The multi-file arm of `DiffBlock` (one card, several path headers) has no producer today: `write`/`edit` each mutate one file per call, so a real card shows one file with one or more hunks. The arm is built and tested for a future multi-file mutation tool, not for a current consumer.
+
+## Testing
+
+`packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%.
+
+`packages/client/ui-conversation/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section.
+
+The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns.
+
+## Related
+
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the same four-layer shape for the `terminal` arm; this note reuses its inline-output decision and its head/tail cap arithmetic.
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this consumes; the Web client is now a consumer of the `diff` arm too.
+- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md) — the slot and snapshot layering the two render sites sit in.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
new file mode 100644
index 0000000000..afdeafa6e9
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
@@ -0,0 +1,57 @@
+# Agent Note: Web diff 卡片 —— write/edit 渲染意图抵达浏览器
+
+Status: implemented
+
+[English](2026-07-30-web-diff-card.md) | 中文
+
+## Problem
+
+`write` 和 `edit` 工具为其 call 和 result 都声明了 `card: 'diff'`([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)):call view 携带从参数推导的预期改动,result view 携带已应用的上下文 hunk(`FileDiff[]`,由 `packages/fs/tool-fs/src/diff.ts` 计算,并持久化在 result `meta` 中以便回放重建)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `callView`/`resultView` 投递到 `ConversationSnapshot` —— TUI 也已将其渲染为按文件分组的 `+`/`-` 块加 `+A -R · N file(s)` 页脚。
+
+Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行从原始工具参数推导,详情面板把 result 的 content block 摊平进一个 `
`。`diffs` 载荷 —— result 的全部意义 —— 被丢弃,于是一次文件改动读起来只是一行确认、看不到任何改动。
+
+这是把 [terminal 卡片](2026-07-28-web-terminal-card.md) 对 `diff` 这一支重做一遍:那次改动让 Web 客户端成为 `terminal` 渲染意图的消费者;这次让它成为 `diff` 渲染意图的消费者,复用同一套四层结构。
+
+## Decision
+
+`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
+
+组件的契约遵循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态:
+
+- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚在两个前端都统计**去重后的路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。
+- **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。
+- **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。
+- **行终止符。** 每一侧的内容按 `TerminalBlock` 的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。本 PR 把同一规则应用到了 TUI diff 分支,因此对于真实 write/edit 调用携带的以换行结尾的内容,两个前端的 `+A -R` 页脚计数一致。
+- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。
+
+几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。
+
+chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX_LINES`(8),对应面板的 16 —— 与 [terminal 卡片](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention)记录的内联输出决策、以及流内表面对单调阅读表面的同一划分一致。write/edit 行是单文件的,所以它的摘要既是可打开的路径链接,其 diff 卡片又展开;两者共存,因为卡片不是路径的参数体。
+
+## Alternatives considered
+
+**并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。
+
+**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR 保持为对既有契约的纯 Web 消费。
+
+**复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token,那是唯一一处一个实现对两者都正确的部分。
+
+## Consequences
+
+`DiffBlock` 只读 diff view 的字段,因此它是渲染意图所携带内容的纯函数 —— 与产出该视图的 presenter 一样回放安全。没有 diff 能力的 UI 仍得到 bridge 的通用回退;工具的 result 形状没有任何改变。无新增运行时依赖:不同于 terminal 卡片的 `anser`,diff 不需要解析器。
+
+`DiffBlock` 的多文件支路(一张卡、多个路径头)今天没有生产者:`write`/`edit` 每次调用各改一个文件,所以真实卡片显示一个文件带一个或多个 hunk。该支路为将来的多文件改动工具而构建并测试,不是为当前消费者。
+
+## Testing
+
+`packages/client/ui-primitives/tests/diff-block.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。
+
+`packages/client/ui-conversation/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。
+
+fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身契约它不带 diff 行为断言,那由接线套件负责。
+
+## Related
+
+- [Web terminal 卡片](2026-07-28-web-terminal-card.md) —— `terminal` 支路的同一套四层结构;本 note 复用其内联输出决策与头尾上限算术。
+- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— 本改动消费的 `card` 标签词汇;Web 客户端现在也是 `diff` 支路的消费者。
+- [Web 客户端架构](../architecture/2026-07-19-gui-web-client-architecture.md) —— 两个渲染点所处的 slot 与快照分层。
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
new file mode 100644
index 0000000000..dae8f18b4d
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
+2026-07-30-web-result-card-frontend.md: d6f4785e83335ca2dd5295516baf47c845ebf5bd
+2026-07-30-web-result-card-frontend.zh.md: ed95cbe39f4f0bf77ba5da64d664705a0841863f
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
new file mode 100644
index 0000000000..d6f4785e83
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
@@ -0,0 +1,49 @@
+# Agent Note: Web result card frontend — rendering the web render intent in the browser
+
+Status: implemented
+
+English | [中文](2026-07-30-web-result-card-frontend.zh.md)
+
+## Problem
+
+The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web result card](2026-07-30-web-result-card.md)): a `kind`-tagged union carrying either the structured cited sources plus an optional provider answer (`kind: 'search'`) or the fetched URL and its HTTP status (`kind: 'fetch'`). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: a completed web call rendered only as its flattened model-facing text, the same lossy render the contract note explains the structured view exists to replace. A `web_search` reached the reader as one free-text markdown line per source rather than a citation list of clickable sources, and a `web_fetch` as its markdown body with no retrieval summary.
+
+## Decision
+
+`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-conversation/src/client/contract/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`).
+
+One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant.
+
+**Links are safe by the http(s) subset of the allowlist MarkdownText applies to untrusted assistant-authored links** — MarkdownText also permits `mailto:`, deliberately excluded here since a retrieval URL is never a mail address. A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:`/`mailto:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse.
+
+**Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock.
+
+The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance and, below it, the flattened model-visible result content: a `web_fetch` card carries only the URL and status, so its fetched body is readable only here.
+
+## Consequences
+
+`WebBlock` reads only the web view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view, and unlike the terminal card it needs no cwd resolution because a web view carries no path. A UI without the `web` capability (the TUI) still gets the contract's fallback `content`; nothing about the tools' result shape changed. `MarkdownText` is reused for the answer, so the answer's own untrusted-link handling and GFM rendering come for free.
+
+A separate later PR unifies the whole-row collapse/expand interaction and will flip every resident card (terminal, diff, web) to expand-gated at once; this card follows the current resident convention rather than pre-empting that change.
+
+## Alternatives considered
+
+**Two components, one per kind.** Rejected: the two shapes share their card chrome, their safe-link handling, and their truncation indicator, and the contract already expresses their difference as a `kind` discriminant under one `card` tag; two components would duplicate the shared surface and split the safe-link logic.
+
+**Reparse the model-facing render text instead of consuming the structured view.** Rejected for the same reason the contract note gives: `web_search`'s render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover `{url, title?, snippet?, publishedAt?}`. The structured `resultView` is the only faithful source, which is why the backend PR added it.
+
+**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist is the http(s) subset of MarkdownText's (which also permits `mailto:`), so untrusted retrieval links behave identically wherever they render.
+
+## Testing
+
+`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap.
+
+`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it.
+
+The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server.
+
+## Related
+
+- [Web result card](2026-07-30-web-result-card.md) — the backend PR that added the `card: 'web'` result arm and made the two tools emit it; this is its deferred frontend consumer.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a `ui-primitives` block, a single card-model derivation, keyed and fallback chat rows, and a details-panel arm, for the `terminal` render intent.
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `web` arm.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md
new file mode 100644
index 0000000000..ed95cbe39f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md
@@ -0,0 +1,49 @@
+# Agent Note: Web result 卡片前端 —— 在浏览器渲染 web 渲染意图
+
+Status: implemented
+
+[English](2026-07-30-web-result-card-frontend.md) | 中文
+
+## Problem
+
+`web_search` 和 `web_fetch` 工具声明了 `card: 'web'` result view([web result card](2026-07-30-web-result-card.md)):一个 `kind` 标签联合,携带结构化的被引用 sources 加可选的 provider answer(`kind: 'search'`),或抓取的 URL 及其 HTTP 状态(`kind: 'fetch'`)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `resultView` 投递到 `ConversationSnapshot` —— 但 Web 客户端忽略了它:一次已完成的 web 调用只渲染为摊平的模型可见文本,正是契约笔记所解释的、结构化视图要替代的那种有损渲染。`web_search` 到达读者时是每个 source 一行自由文本 markdown,而非可点击 source 的引用列表;`web_fetch` 是它的 markdown 正文,没有检索摘要。
+
+## Decision
+
+`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。
+
+一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。
+
+**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集。** MarkdownText 还允许 `mailto:`,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:`/`mailto:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。
+
+**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。
+
+卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。
+
+## Consequences
+
+`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到契约的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。
+
+一条独立的后续 PR 会统一整行折叠/展开交互,并把每张常驻卡片(terminal、diff、web)一次性翻成 expand-gated;本卡片遵循当前的常驻约定,而非抢先做那次改动。
+
+## Alternatives considered
+
+**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而契约已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。
+
+**重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。
+
+**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还允许 `mailto:`)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。
+
+## Testing
+
+`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。
+
+`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。
+
+fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。
+
+## Related
+
+- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。
+- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。
+- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml
new file mode 100644
index 0000000000..455c89ebcd
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md
+2026-07-30-web-tool-row-unified-expand-and-inspect.md: ba2f4ead8023772fad578ca0b647241ecc332905
+2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: ac4835c7429a3ff7d3042f73d26d267911533132
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md
new file mode 100644
index 0000000000..ba2f4ead80
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md
@@ -0,0 +1,34 @@
+# Agent Note: Web tool-row unified expand and trajectory Inspect
+
+Status: implemented
+
+English | [中文](2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md)
+
+## Problem
+
+The chat view's tool rows had drifted into per-surface interaction dialects: ToolRow expanded through a leading-icon toggle and only for calls with an args body, the bash sample had its own expand affordance, todo/ask-question rows expanded raw args only, single-file tools were not expandable at all, and a call's OUTPUT was reachable only through the details panel. A failing bash command (exit≠0 settles `isError:false`) showed no collapsed-row failure signal. There was also no path from a chat row to its trajectory record, and switching chat → trajectory → chat lost the reader's scroll position because the tab ring unmounts inactive views.
+
+## Decision
+
+**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its scroll offset across view switches through an in-memory per-session map.**
+
+- `toolRowModel` now derives result material alongside args: `output` (the `resultText` flatten, moved from DetailsPanel into the contract), and `errorSummary` (the failure's first line, shown as the collapsed summary in the error color). A row with body, output, or terminal material is expandable; the row itself is the toggle (`role="button"`, `aria-expanded`), and file-path summaries stay independent links via `stopPropagation`.
+- The expanded card (figma 1249:35657) is a column of IN/OUT sections: each section is its own scrollport (max-height 150px) with a sticky gutter label, and the l2 divider spans the full card width. Think prose and the run_code CodeBlock keep their non-card bodies; context injection reuses the row with a label-less `plainBody` card.
+- `terminalFailed` reads a settled terminal card's exit status so BashRow and GenericToolCard surface a failing command as the row's red state dot — the only failure signal the collapsed row has, since the call itself settles `isError:false`.
+- TerminalBlock's banner joins the same reading model: it shares the card surface (no banner token), an l2 hairline separates it from the body, the command column caps at 150px and scrolls with sticky copy/status controls top-aligned to the first prompt row.
+- Inspect: `ToolRowOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field.
+- Scroll preservation: the chat view saves its offset on every scroll (null when pinned to bottom) into an apply-scope `Map` exposed as `chatScroll` on the injected props; the open-jump branch restores it on remount. Deliberately not persisted — a fresh page load keeps the open-jump-to-bottom default.
+
+## Alternatives considered
+
+**Keeping the leading-icon toggle and per-registrant expand affordances.** Rejected: three surfaces had already diverged; the registrant posture (bash sample replicates CSS locally) makes drift permanent unless the interaction contract itself is uniform and small — whole-row toggle plus hover preview.
+
+**Routing Inspect through a URL or a trajectory-view prop.** Rejected: the view ring renders through the slot registry, so the two views share no parent that could carry a prop; the chat store already crosses that boundary and the one-shot field keeps the handoff replay-safe (persisted snapshots from before the field rehydrate with `?? null`).
+
+**Persisting the chat scroll offset.** Rejected: restoring a days-old offset into a conversation that has since grown reads as a bug; the in-memory map scopes the memory to exactly the view-switch case that loses it.
+
+**A per-row expanded OUTPUT fetched from the details panel's material.** Unnecessary: the settled result node already rides the snapshot's frozen call slice, so the contract-level `resultText` flatten serves both the row and the panel from one derivation.
+
+## Consequences
+
+Any registered toolview gets input AND output inspection in place, with the details panel and trajectory remaining the deep-dive surfaces. The unified interaction is contract-visible (`ToolRowProps.output/errorSummary/inspect`), so third-party rows opt in by passing model fields through. The bash sample intentionally re-replicates the new CSS (registrant posture), so future interaction changes still touch it by hand. `--dsw-font-markdown-code-block-small` (12/18) is a hand-added token pending a design-platform export. The web-cordis `distIndex` fix (plain concatenation, not URL.pathname) unblocks preview boots from a cwd with spaces.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md
new file mode 100644
index 0000000000..ac4835c742
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md
@@ -0,0 +1,34 @@
+# Agent Note:Web 工具行统一展开交互与 trajectory Inspect
+
+状态:已实现
+
+[English](2026-07-30-web-tool-row-unified-expand-and-inspect.md) | 中文
+
+## 问题
+
+聊天视图的工具行交互已经分裂成多种方言:ToolRow 通过前导图标切换展开、且仅限有 args body 的调用,bash 示例有自己的一套展开方式,todo / ask-question 行只能展开原始 args,单文件工具完全不可展开,而调用的 OUTPUT 只能通过右侧详情面板查看。失败的 bash 命令(exit≠0 但结算为 `isError:false`)在折叠行上没有任何失败信号。此外聊天行没有跳转到 trajectory 记录的入口,且 chat → trajectory → chat 切换会丢失阅读位置(标签环会卸载非活跃视图)。
+
+## 决定
+
+**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留滚动位置。**
+
+- `toolRowModel` 在 args 之外同时派生结果材料:`output`(`resultText` 拍平逻辑从 DetailsPanel 移入 contract)和 `errorSummary`(失败首行,以错误色作为折叠摘要)。有 body、output 或 terminal 材料的行即可展开;行本身是开关(`role="button"`、`aria-expanded`),文件路径摘要通过 `stopPropagation` 保持独立链接。
+- 展开卡片(figma 1249:35657)是 IN/OUT 分区列:每个分区是独立滚动区(max-height 150px),侧栏标签 sticky 固定,l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。
+- `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`。
+- TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token),与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。
+- Inspect:`ToolRowOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。
+- 滚动保留:聊天视图在每次滚动时保存偏移(贴底时为 null)到 apply 作用域的 `Map`,经注入 props 的 `chatScroll` 暴露;重挂载时 open-jump 分支恢复它。刻意不持久化——新页面加载保持打开即贴底的默认行为。
+
+## 曾考虑的替代方案
+
+**保留前导图标开关和各注册方自有的展开方式。** 否决:三个表面已经分化;注册方姿态(bash 示例本地复刻 CSS)意味着除非交互契约本身统一且足够小——整行开关加 hover 预览——否则漂移会永久存在。
+
+**通过 URL 或 trajectory 视图 prop 传递 Inspect。** 否决:视图环经由 slot 注册表渲染,两个视图没有可携带 prop 的共同父级;chat store 本就跨越该边界,一次性字段让交接可安全重放(字段出现之前的持久化快照以 `?? null` 复水)。
+
+**持久化聊天滚动偏移。** 否决:把几天前的偏移恢复到已经增长的会话里读起来像 bug;内存 Map 把记忆精确限定在会丢位置的视图切换场景。
+
+**从详情面板的材料为每行单独取展开 OUTPUT。** 不必要:已结算结果节点本就在快照的冻结调用切片上,contract 层的 `resultText` 拍平让行和面板共用一份派生。
+
+## 后果
+
+任何已注册 toolview 都能就地查看输入与输出,详情面板和 trajectory 仍是深查表面。统一交互契约可见(`ToolRowProps.output/errorSummary/inspect`),第三方行透传模型字段即可接入。bash 示例有意重新复刻新 CSS(注册方姿态),未来交互变更仍需手动同步它。`--dsw-font-markdown-code-block-small`(12/18)是手工补充的 token,待设计平台导出后替换。web-cordis 的 `distIndex` 修复(纯拼接而非 URL.pathname)解除了含空格 cwd 下预览无法启动的问题。
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml
new file mode 100644
index 0000000000..1c2cbd0216
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
+2026-07-31-gui-full-access-confirmation.md: 8208a20bee9b9ab8f1e73720790e3e5be4c27306
+2026-07-31-gui-full-access-confirmation.zh.md: 8ac115034f562fe96d53bdba010b05648d4d5947
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
new file mode 100644
index 0000000000..8208a20bee
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
@@ -0,0 +1,30 @@
+# Agent Note: GUI Full access risk confirmation
+
+Status: implemented
+
+English | [中文](2026-07-31-gui-full-access-confirmation.zh.md)
+
+## Problem
+
+Switching the web client to `danger-full-access` was a single click on either permission surface (the composer's Access chip and the `/permission` popup picker), with the preset shown as the title-cased machine name `Danger Full Access`. Full access reduces confirmation steps and lets the agent run sensitive operations, modify files, or execute external commands, so an accidental pick armed the most dangerous preset with no deliberate acknowledgement step.
+
+## Decision
+
+**Both permission surfaces gate `danger-full-access` behind one shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
+
+- `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
+- The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
+- The `/permission` popup (ui-permission over the ui-command shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
+- `Full access` intentionally overrides the kebab-to-title display transform on both surfaces (option rows, trigger label, settled command rows keep the machine name on the wire); the warning body remains locale-aware in Chinese and English.
+
+## Alternatives considered
+
+**A native/OS or separate-window confirmation.** Rejected: the dialog must stay inside the current WebUI window; a second window can appear on another display and detaches the decision from the page state it guards.
+
+**One shared locale namespace for both surfaces' safety copy.** Rejected: the ui-permission bundle and ui-conversation load independently, so each registers the same copy under its own namespace (`permission.access` beside the conversation dictionary); the duplication is fenced with an explanatory `jscpd:ignore` block rather than a cross-bundle import.
+
+**Gating in the host/permission backend.** Out of scope by design: the change is browser-client confirmation flow only; backend permission semantics, defaults, and the safer presets' one-click behavior are unchanged.
+
+## Consequences
+
+Every visible GUI path into Full access now requires a deliberate, informed acknowledgement, at the cost of one extra dialog step for users who genuinely want the preset. New pickers reuse the gate by attaching a `confirmation` payload (popup path) or the chip's state machine (composer path) instead of inventing bespoke dialogs. Acceptance: the composer flow's four gated cases in `input-bar.spec.tsx`, the popup gate in `popup-view.spec.tsx` and `popup.spec.ts`, the Modal/RiskConfirmation contract in `atoms.spec.tsx`, and the assembled `access-confirmation` web e2e whose golden pins the product-default Chinese dictionary copy.
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md
new file mode 100644
index 0000000000..8ac115034f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md
@@ -0,0 +1,30 @@
+# Agent Note: GUI Full access 风险确认
+
+Status: implemented
+
+[English](2026-07-31-gui-full-access-confirmation.md) | 中文
+
+## Problem
+
+Web 客户端切换到 `danger-full-access` 在两个权限面(编辑器的 Access chip 与 `/permission` popup 选择器)上都只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
+
+## Decision
+
+**两个权限面都把 `danger-full-access` 关进同一个共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不提交任何命令。**
+
+- `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` 座位,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
+- 编辑器 chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale 座位以 `access.confirm.*` 键供给。
+- `/permission` popup(ui-permission 骑在 ui-command 外壳上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
+- `Full access` 在两个面上有意覆盖 kebab 转 Title Case 的显示变换(选项行、触发器标签;落定的命令行仍在 wire 上保留机器名);警示正文保持中英文 locale 感知。
+
+## Alternatives considered
+
+**原生/操作系统或独立窗口确认。** 已拒:对话框必须留在当前 WebUI 窗口内;第二个窗口可能出现在另一块显示器上,使决策脱离其守护的页面状态。
+
+**两个面共享一个安全文案 locale namespace。** 已拒:ui-permission bundle 与 ui-conversation 可独立加载,故各自在自己的 namespace 下注册同一份文案(`permission.access` 与 conversation 词典并立);这处重复以带说明的 `jscpd:ignore` 块圈护,而非跨 bundle import。
+
+**在 host/权限后端把关。** 设计上即出界:本变更只涉浏览器客户端确认流;后端权限语义、默认值与更安全预设的一键行为均不变。
+
+## Consequences
+
+进入 Full access 的每条可见 GUI 路径现在都要求刻意且知情的确认,代价是真想启用该预设的用户多一步对话框。新的选择器复用此门:popup 路径挂 `confirmation` 载荷、编辑器路径走 chip 的状态机,而不是各造对话框。验收:`input-bar.spec.tsx` 中编辑器流的四个门控用例、`popup-view.spec.tsx` 与 `popup.spec.ts` 的 popup 门、`atoms.spec.tsx` 的 Modal/RiskConfirmation 契约,以及组装态 `access-confirmation` web e2e——其 golden 钉住产品默认中文词典文案。
diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml
index 2cb7f1009d..50d3e498a5 100644
--- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml
@@ -1,6 +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
-2026-07-06-node-engine-floor.md: f1754ea7ca32452a04c6cd8a0599568f602e47dd
-2026-07-06-node-engine-floor.zh.md: 9d376a639378d3a0b9b645aa36c1a5d320d1d147
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-node-engine-floor.md
+2026-07-06-node-engine-floor.md: ef047d885a442106a35922f4716d2996d8a98ca7
+2026-07-06-node-engine-floor.zh.md: a0281addf7d4327d7f6ea30e3a3f0f40d6782bd0
diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md
index f1754ea7ca..ef047d885a 100644
--- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md
+++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md
@@ -10,7 +10,7 @@ The Node 22 branch of the root `engines.node` range is a contract for the instal
 
 ## Decision
 
-Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibility matrix on `['22.19', 24, 26]`. Every matrix leg runs the TypeScript typecheck plus a keyless source-mode worker smoke, so the floor is exercised through both a complete source typecheck and a real unbuilt runtime path. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor.
+Set `engines.node` to `^22.19.0 || >=24.0.0` and test keyless CI on `['22.19', 24, 26]`. The primary Node 24 jobs own the complete typecheck and unit coverage inventory; every version runs focused source-worker, Zstandard, source-launch, and [jsdom storage](../testing/2026-07-30-vitest-jsdom-webstorage-ownership.md) smokes without repeating that inventory. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor.
 
 Two Node features gate the source runtime:
 
@@ -24,7 +24,7 @@ Those source features clear on the 22.x line at **22.18**, but the installed Pi
 ## Consequences
 
 - The advertised LTS branch no longer undercuts the Pi adapter dependency floor.
-- CI proves the Node 22 LTS floor directly with Node 22.19, keeps the Node 24 branch on `node: 24`, and keeps Node 26 for the next even line; each leg typechecks the source graph and launches the unbuilt workflow worker for real.
+- CI proves the Node 22 LTS floor directly with Node 22.19, keeps primary coverage on `node: 24`, and exercises Node 26 as the next even line; focused compatibility smokes run on all three versions.
 - The built-bin smoke needs no version-conditional flag: at 22.19 type-stripping is already the default, so the test stays the plain `node lib/bin.js` path it documents.
 - A future dependency or source API that raises the runtime floor must move `engines.node`, the compatibility matrix, and this Agent Note in the same change.
 
diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md
index 9d376a6393..a0281addf7 100644
--- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md
+++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md
@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决策
 
-将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 keyless CI 兼容性矩阵中测试 `['22.19', 24, 26]`。每条矩阵分支都运行 TypeScript 类型检查加一次 keyless 的源码模式 worker 冒烟测试,因此引擎下限通过完整的源码类型检查和真实的未构建运行时路径两条路径得到验证。真实 API 的 e2e 工作流保持在 Node 24 上,因为它验证的是 API 集成而非运行时下限。
+将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 `['22.19', 24, 26]` 上运行 keyless CI。主要的 Node 24 任务负责整套类型检查和单元测试覆盖率任务;三个版本均运行 source-worker、Zstandard、source-launch 和 [jsdom 存储](../testing/2026-07-30-vitest-jsdom-webstorage-ownership.md) 专项冒烟测试,不重复这套类型检查和覆盖率任务。真实 API 的 e2e 工作流保持在 Node 24 上,因为它验证的是 API 集成而非运行时下限。
 
 两个 Node 特性决定了源码运行时的门槛:
 
@@ -24,7 +24,7 @@ Status: implemented
 ## 后果
 
 - 宣传的 LTS 分支不再低于 Pi 适配器依赖的下限。
-- CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,Node 24 分支保持 `node: 24`,Node 26 用于下一个偶数线;每条分支都对源码图执行类型检查,并实际启动未构建的工作流 worker。
+- CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,将主要覆盖率任务保留在 `node: 24`,并用 Node 26 验证下一个偶数线;三个版本均运行聚焦的兼容性冒烟测试。
 - built-bin 冒烟测试无需版本条件标志:在 22.19 上类型剥离已是默认行为,因此测试保持其文档所述的纯 `node lib/bin.js` 路径。
 - 未来若依赖或源码 API 提高运行时下限,必须在同一变更中同步调整 `engines.node`、兼容性矩阵和本 Agent Note(agent 决策记录)。
 
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml
new file mode 100644
index 0000000000..70c5ce5a90
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md
+2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e
+2026-07-22-product-first-root-readme.zh.md: 1c4d5fa53854bfcade9742da1fb74d9636909f84
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md
new file mode 100644
index 0000000000..32542a4501
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md
@@ -0,0 +1,33 @@
+# Agent Note: Product-first root README
+
+Status: implemented
+
+English | [中文](2026-07-22-product-first-root-readme.zh.md)
+
+## Problem
+
+The root README is the repository's product front door. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works.
+
+## Decision
+
+The root README preserves its existing structure, order, and wording wherever the underlying fact remains correct. A refresh changes only stale claims and adds material needed to represent shipped surfaces; it does not use repository growth as a reason to reframe the whole page.
+
+A note before installation thanks internal testers, states that features and experience remain unfinished, and asks for direct reports of failures, confusion, and friction through the WeCom group. The existing development-stage statement identifies DeepSeek Harness as being in internal testing.
+
+The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The installed TUI remains the single `dsh` command; the Web instructions build the active checkout before running `dsh web`, and custom or reused checkout paths stay explicit. These launch paths must remain executable through a real PTY and a production build/HTTP smoke, respectively. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, settings, credentials, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it.
+
+Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps its separate user-guide landing page.
+
+## Alternatives considered
+
+**Rewrite the README around a new product narrative.** A complete rewrite can make every current surface prominent, but it replaces accurate, reviewed copy and creates unnecessary churn. Current facts fit the established product-first structure.
+
+**Present the repository as an SDK and package catalog.** This exposes implementation breadth immediately but makes a new reader reconstruct the product from package names. The package map and generated capability graph remain the authoritative inventories.
+
+**Use a long marketing page with screenshots, badges, and duplicated tutorials.** Rich media can demonstrate a stable product journey, but it ages separately from commands and source contracts. The root stays compact and links to runnable examples and owned guides.
+
+**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs.
+
+## Consequences
+
+Reviewers can distinguish factual refreshes from editorial rewrites, and future updates retain established wording unless its meaning becomes false or incomplete. The README must still change with affected commands, entry points, release-stage claims, or high-level capability families, while exhaustive detail remains linked rather than copied.
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md
new file mode 100644
index 0000000000..1c4d5fa538
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 产品优先的根 README
+
+Status: implemented
+
+[English](2026-07-22-product-first-root-readme.md) | 中文
+
+## 问题
+
+根 README 是仓库的产品入口。其产品优先的结构和既有语气仍然有效,但随着运行时不断扩展,具体入口和能力声明会逐渐陈旧。重写事实仍然正确的章节,会扩大评审范围,也会丢弃已经行之有效的措辞。
+
+## 决策
+
+只要背后的事实仍然正确,根 README 就保留既有结构、顺序和措辞。刷新时只修正陈旧声明,并补充呈现已交付内容所需的信息;不会因为仓库规模增长就重构整篇叙事。
+
+安装说明之前的一则文字感谢内测用户,说明功能和体验仍待完善,并邀请大家通过企业微信群直接反馈失败、困惑和不顺手之处。既有的开发阶段声明明确说明 DeepSeek Harness 处于内测阶段。
+
+用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。安装后的 TUI 仍只需执行一条 `dsh` 命令;Web 说明要求先构建当前检出,再运行 `dsh web`,并明确处理自定义或复用的检出路径。这两条启动路径必须分别能在真实 PTY 与生产构建/HTTP 冒烟中原样执行。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、设置、凭据、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。
+
+包(package)与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页。
+
+## 考虑过的替代方案
+
+**围绕新的产品叙事重写 README。** 完整重写能够突出所有现有入口和能力,但也会替换准确且已经过评审的文案,造成不必要的变动。现有事实能够纳入既有的产品优先结构。
+
+**将仓库呈现为 SDK 和包清单。** 这样能立即展现实现广度,却会迫使新读者从包名反推出产品。包索引与生成的能力图仍是权威清单。
+
+**使用包含截图、徽章和重复教程的长篇营销页面。** 富媒体能够展示稳定的产品使用路径,但其内容会独立于命令和源码契约而逐渐陈旧。根 README 保持紧凑,并链接到可运行示例和各自维护的指南。
+
+**将根 README 投影为文档网站首页。** 使用同一个首页可以避免两套叙事,但文档网站的用户指南与仓库面向产品和开发者的入口在导航和维护需求上并不相同。
+
+## 结果
+
+评审者可以区分事实更新与编辑性重写;今后的更新会保留既有措辞,除非其含义已经不再正确或完整。受影响的命令、入口、发布阶段声明或高层能力类别发生变化时,README 仍须同步更新;完整细节则继续以链接方式提供,而不是复制到正文。
diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml
new file mode 100644
index 0000000000..e829874e9d
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md
+2026-07-30-vitest-jsdom-webstorage-ownership.md: 3956a7566fa1c79a767636bce9a19f16588126e2
+2026-07-30-vitest-jsdom-webstorage-ownership.zh.md: 9080ee2762b74bf2efdaccd7a5905672001bc0e8
diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md
new file mode 100644
index 0000000000..3956a7566f
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md
@@ -0,0 +1,26 @@
+# Agent Note: Keep browser storage owned by jsdom in Vitest
+
+Status: implemented
+
+English | [中文](2026-07-30-vitest-jsdom-webstorage-ownership.zh.md)
+
+## Problem
+
+The supported Node range includes releases that reserve a process-wide `globalThis.localStorage`. Node 26 exposes that property as `undefined` without `--localstorage-file`; Vitest sees the reserved key and does not project jsdom's isolated `Storage` object over it. Component suites then fail before exercising product behavior, while the primary Node 24 coverage lane remains green because that runtime does not reserve the key by default.
+
+## Decision
+
+Vitest workers disable Node's process-wide Web Storage when the runtime advertises the `--webstorage` flag. The configuration passes `--no-webstorage` through each test project's `execArgv`; runtimes without that flag receive no argument. Node-environment suites therefore stay browser-free, and files selecting jsdom through `@vitest-environment jsdom` receive jsdom's isolated `localStorage`.
+
+The Node compatibility aggregate runs a dedicated jsdom smoke on every advertised compatibility line. It asserts both the conditional worker argument and usable storage, so a future Node or Vitest change cannot leave the primary Node 24 suite as the only signal.
+
+## Alternatives considered
+
+- **Set `NODE_OPTIONS=--no-webstorage` in package scripts or CI.** Rejected because it leaks test-runner policy into subprocesses and misses direct `pnpm exec vitest` invocations.
+- **Pass `--localstorage-file` to Node.** Rejected because one process-wide persistent store has different ownership and isolation semantics from browser storage created per jsdom environment.
+- **Patch `globalThis.localStorage` in setup code or guard every component test.** Rejected because setup would depend on Vitest's private jsdom projection details, while per-test guards hide a broken browser environment and duplicate policy across suites.
+- **Pin tests to Node 24.** Rejected because the package engine advertises newer even Node lines and the compatibility matrix exists to expose their runtime changes.
+
+## Consequences
+
+The same `pnpm test` command works on Node releases with and without built-in Web Storage. Test workers deliberately cannot exercise Node's process-wide Web Storage; a future product need for that API requires a separate explicit test configuration rather than weakening jsdom isolation. The compatibility lane adds one focused Vitest process instead of duplicating the complete unit inventory on every Node version.
diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md
new file mode 100644
index 0000000000..9080ee2762
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md
@@ -0,0 +1,26 @@
+# Agent Note: 在 Vitest 中将浏览器存储交由 jsdom 管理
+
+Status: implemented
+
+[English](2026-07-30-vitest-jsdom-webstorage-ownership.md) | 中文
+
+## 问题
+
+受支持的 Node 版本范围包含会预留进程级 `globalThis.localStorage` 的版本。未设置 `--localstorage-file` 时,Node 26 将该属性暴露为 `undefined`;Vitest 检测到这个预留键后,不会用 jsdom 的隔离 `Storage` 对象覆盖该属性。因此,组件测试套件尚未验证产品行为便会失败,而主要的 Node 24 覆盖率分支仍能通过,因为该运行时默认不会预留此键。
+
+## 决策
+
+当运行时声明支持 `--webstorage` 标志时,Vitest worker 会禁用 Node 的进程级 Web Storage。配置通过每个测试项目的 `execArgv` 传入 `--no-webstorage`;未声明该标志的运行时则不传入此参数。因此,Node 环境测试套件不加载浏览器环境,而通过 `@vitest-environment jsdom` 选择 jsdom 的文件会获得 jsdom 隔离的 `localStorage`。
+
+Node 兼容性汇总任务会在每条声明支持的兼容版本线上运行专用的 jsdom 冒烟测试。该测试同时断言 worker 参数按条件传入且存储可用,因此未来 Node 或 Vitest 的变化不会让主要的 Node 24 测试套件成为唯一检测信号。
+
+## 曾考虑的替代方案
+
+- **在包脚本或 CI 中设置 `NODE_OPTIONS=--no-webstorage`。** 否决:这会将测试运行器策略传播到子进程,也无法覆盖直接调用 `pnpm exec vitest` 的情况。
+- **向 Node 传入 `--localstorage-file`。** 否决:单个进程级持久化存储与每个 jsdom 环境分别创建的浏览器存储具有不同的归属和隔离语义。
+- **在初始化代码中修改 `globalThis.localStorage`,或为每个组件测试增加保护逻辑。** 否决:初始化逻辑会依赖 Vitest 私有的 jsdom 映射细节,而逐测试添加的保护逻辑会掩盖浏览器环境损坏,并在多个测试套件中重复该策略。
+- **将测试固定在 Node 24。** 否决:包的引擎范围声明支持更新的偶数 Node 版本线,而兼容性矩阵正是为了暴露这些版本的运行时变化。
+
+## 后果
+
+同一条 `pnpm test` 命令在有无内置 Web Storage 的 Node 版本上均可运行。测试 worker 被有意禁止使用 Node 的进程级 Web Storage;未来若产品需要该 API,必须使用独立且显式的测试配置,而不能削弱 jsdom 隔离。兼容性分支只增加一个专项 Vitest 进程,无需在每个 Node 版本上重复整套单元测试。
diff --git a/README.i18n.yaml b/README.i18n.yaml
index 7584d4f293..66400262db 100644
--- a/README.i18n.yaml
+++ b/README.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write README.md
-README.md: f9f7294b42e29132d5cd46c0ab6a5f5265a1d8f3
-README.zh.md: 88cbf8522d8f1a183a48dc7e80858d1a0ced8f0f
+README.md: baf5d79b157ae845cc837261452853afd48dbe46
+README.zh.md: 57d7bcf44cda36b37ae233754dbfba4ead2204fd
diff --git a/README.md b/README.md
index f9f7294b42..baf5d79b15 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,16 @@ DeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Ha
 
 It uses an architecture where **everything is a plugin**.
 
+## Internal testing notice
+
+Thank you for making time to try DeepSeek Harness.
+
+This version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.
+
+“As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs.
+
+We especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it.
+
 ## Install
 
 Install `dsh` with one command:
@@ -22,20 +32,14 @@ The installer keeps every checkout under `~/.dsh/source`: the master clone at `~
 
 ### Web UI
 
-For the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink):
+For the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:
 
 ```sh
-dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)")
-while [ -L "$dsh_bin" ]; do
-  link=$(readlink "$dsh_bin")
-  case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac
-done
-dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P)
-pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web
+(cd ~/.dsh/source/current && pnpm run build)
 dsh web
 ```
 
-The Web UI is served at `http://127.0.0.1:3080` by default.
+The full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.
 
 ### TUI
 
@@ -53,11 +57,22 @@ Run one task, print the final answer, and exit:
 dsh -p "summarize this workspace"
 ```
 
+### Automation and SDKs
+
+From a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:
+
+```sh
+pnpm run demo:acp
+```
+
+The [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.
+
 ## Why DeepSeek Harness
 
-Built-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.
+Built-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.
 
 - **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.
+- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).
 - **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).
 - **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).
 
@@ -76,7 +91,7 @@ Start with the [development guide](docs/development.md) and read the [architectu
 
 For agents, follow [AGENTS.md](AGENTS.md).
 
-DeepSeek Harness is currently pre-release.
+DeepSeek Harness is currently in internal testing.
 
 ## License
 
diff --git a/README.zh.md b/README.zh.md
index 88cbf8522d..57d7bcf44c 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -6,6 +6,16 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源
 
 它采用了**一切皆插件**的架构。
 
+## 内测声明
+
+感谢您愿意拨冗试用 DeepSeek Harness。
+
+目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。
+
+“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。
+
+我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。
+
 ## 安装
 
 使用一条命令安装 `dsh`:
@@ -22,20 +32,14 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m
 
 ### Web UI
 
-推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析):
+推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:
 
 ```sh
-dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)")
-while [ -L "$dsh_bin" ]; do
-  link=$(readlink "$dsh_bin")
-  case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac
-done
-dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P)
-pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web
+(cd ~/.dsh/source/current && pnpm run build)
 dsh web
 ```
 
-Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。
+完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。
 
 ### TUI
 
@@ -53,11 +57,22 @@ dsh
 dsh -p "summarize this workspace"
 ```
 
+### 自动化与 SDK
+
+在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:
+
+```sh
+pnpm run demo:acp
+```
+
+[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。
+
 ## 为什么选择 DeepSeek Harness
 
-内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。
+内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。
 
 - **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。
+- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。
 - **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。
 - **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。
 
@@ -80,7 +95,7 @@ pnpm run test:coverage
 
 面向 agent:遵循 [AGENTS.md](AGENTS.md)。
 
-DeepSeek Harness 目前处于预发布阶段。
+DeepSeek Harness 目前处于内测阶段。
 
 ## 许可证
 
diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml
index 8c66cde1e2..bce3099226 100644
--- a/apps/cli/README.i18n.yaml
+++ b/apps/cli/README.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: a686cc7f3a783faed1e6b8a5c85306bd61e2992b
-README.zh.md: 24db23715c8b4186ff491b17f9bacbd9b06ae6a2
+README.md: db4489c6087f5e844dcc2dda723d208c8137186d
+README.zh.md: 7880e98459a844cdb8124ed89c0ef90ea6301868
diff --git a/apps/cli/README.md b/apps/cli/README.md
index a686cc7f3a..db4489c608 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -18,8 +18,7 @@ The TUI surface:
 
 `dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection.
 
-
-The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
+The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
 
 The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
 
diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md
index 24db23715c..7880e98459 100644
--- a/apps/cli/README.zh.md
+++ b/apps/cli/README.zh.md
@@ -18,8 +18,7 @@ TUI 界面:
 
 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。
 
-
-Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
+Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
 
 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
 
diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml
index 40e5546a20..9ec2b780ef 100644
--- a/apps/cli/config/base.cordis.yml
+++ b/apps/cli/config/base.cordis.yml
@@ -88,7 +88,8 @@
       (() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })()
 
 # TUI consumes this shared session capability. Its launcher supplies a unique
-# process-local path; non-TUI surfaces disable the row in their overlay.
+# process-local path; other surfaces repoint or disable the row in their
+# overlay (web patches it to an ephemeral in-memory index).
 - id: session-query-sqlite
   name: '@deepseek-ai/dsh-session-query-sqlite'
   config:
diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml
index a2fc10804d..a67d95ff6b 100644
--- a/apps/cli/config/web.cordis.yml
+++ b/apps/cli/config/web.cordis.yml
@@ -14,9 +14,14 @@
 - id: hmr
   disabled: true
 
-# Session query is a TUI capability; Web owns its own session presentation.
+# Web content search runs on an ephemeral in-memory index. The service
+# activates at boot, while first-search defers the node:sqlite import and
+# in-memory handle so Node 22 startup stays quiet until content search
+# actually uses SQLite. That search then reconciles this boot's sources.
 - id: session-query-sqlite
-  disabled: true
+  config:
+    path: ':memory:'
+    openAt: first-search
 
 - id: tools
   config:
diff --git a/apps/cli/package.json b/apps/cli/package.json
index b84e264859..c1f954e419 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -80,6 +80,7 @@
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
+    "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
     "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts
index 6ac88d23c5..54ade122c7 100644
--- a/apps/cli/src/app-cli-entry.ts
+++ b/apps/cli/src/app-cli-entry.ts
@@ -3,7 +3,7 @@
  * for the Web/headless surface.
  * Everything here is what must exist before the Loader runs: the patch
  * composition over the shipped base and surface overlay (profile json + CLI
- * flags + the resolved frontend dist), and the fail-loud triple after the tree
+ * flags + the resolved frontend dist), and the fail-loud activation audit after the tree
  * settles. The environment is what the bin already loaded (ambient plus the
  * invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
  * provider and is never hoisted here.
@@ -246,7 +246,7 @@ export class AppCLIEntry {
     if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
   }
 
-  /** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */
+  /** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */
   private async bootTree(): Promise {
     // One include of the shared base with every overlay as a sibling patch
     // list: patches never cross an include boundary, so nesting them would
diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts
index 6c37598e11..9c6a2ec94c 100644
--- a/apps/cli/src/web.ts
+++ b/apps/cli/src/web.ts
@@ -57,12 +57,14 @@ export async function runWeb(
     void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
   }
 
+  // Install shutdown handling before publishing readiness: supervisors may
+  // send a signal as soon as they observe the URL line.
+  process.on('SIGTERM', () => { shutdown(0) })
+  process.on('SIGINT', () => { shutdown(130) })
+
   // The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
   // must name an address the /api trust fence was configured with.
   const lanCandidate = entry.lanAddresses[0]
   const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
   console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
-
-  process.on('SIGTERM', () => { shutdown(0) })
-  process.on('SIGINT', () => { shutdown(130) })
 }
diff --git a/apps/cli/tests/lazy-search-startup.compat.spec.ts b/apps/cli/tests/lazy-search-startup.compat.spec.ts
new file mode 100644
index 0000000000..6e6d0b6e85
--- /dev/null
+++ b/apps/cli/tests/lazy-search-startup.compat.spec.ts
@@ -0,0 +1,112 @@
+/**
+ * Node 22 startup-output smoke for the shipped Web CLI composition.
+ *
+ * Only the dedicated Node compatibility gate opts this test in after building
+ * both artifacts; ordinary Vitest inventory deterministically skips it.
+ * The child runs built artifacts under plain Node with the real shipped
+ * config (base.cordis.yml + the web.cordis.yml overlay).
+ * Its URL line follows AppCLIEntry's settled boot; SIGTERM then exercises the
+ * shipped quiescent disposer.
+ */
+
+import { spawn } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import yaml from 'js-yaml'
+import { describe, expect, it } from 'vitest'
+
+const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
+const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
+const webDist = join(repoRoot, 'apps/web/dist/index.html')
+// The web overlay owns the session-query-sqlite lazy-open patch row.
+const configPath = join(repoRoot, 'apps/cli/config/web.cordis.yml')
+const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1'
+
+interface ConfigRow {
+  id?: string
+  config?: { openAt?: unknown }
+}
+
+const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
+  kind: 'scalar',
+  construct: value => String(value),
+})
+const configSchema = yaml.JSON_SCHEMA.extend(jsExprType)
+
+/** Boot the built Web CLI, wait for its settled URL, then dispose through SIGTERM. */
+function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
+  return new Promise((resolveRun, rejectRun) => {
+    const env: NodeJS.ProcessEnv = {
+      ...process.env,
+      DEEPSEEK_API_KEY: 'dsh-cli-smoke-dummy-key',
+      DSH_HOME: join(cwd, '.dsh'),
+    }
+    delete env.DEEPSEEK_BASE_URL
+    delete env.NODE_OPTIONS
+    delete env.NODE_NO_WARNINGS
+    const child = spawn(process.execPath, [
+      builtBin,
+      'web',
+      '--host',
+      '127.0.0.1',
+      '--port',
+      '0',
+    ], {
+      cwd,
+      env,
+      stdio: ['ignore', 'pipe', 'pipe'],
+    })
+    let stdout = ''
+    let stderr = ''
+    let settled = false
+    child.stdout.setEncoding('utf8')
+    child.stderr.setEncoding('utf8')
+    child.stdout.on('data', (chunk: string) => {
+      stdout += chunk
+      if (!settled && /dsh web: http:\/\/127\.0\.0\.1:\d+/u.test(stdout)) {
+        settled = true
+        child.kill('SIGTERM')
+      }
+    })
+    child.stderr.on('data', (chunk: string) => { stderr += chunk })
+    const timer = setTimeout(() => {
+      child.kill('SIGKILL')
+      rejectRun(new Error(`built Web CLI did not settle and dispose within 60s\nstdout:\n${stdout}\nstderr:\n${stderr}`))
+    }, 60_000)
+    child.on('error', (error) => {
+      clearTimeout(timer)
+      rejectRun(error)
+    })
+    child.on('close', (code) => {
+      clearTimeout(timer)
+      if (!settled) {
+        rejectRun(new Error(`built Web CLI exited before settled startup (code ${String(code)})\nstdout:\n${stdout}\nstderr:\n${stderr}`))
+        return
+      }
+      resolveRun({ stdout, stderr, code: code ?? -1 })
+    })
+  })
+}
+
+describe.skipIf(!requireBuiltArtifacts)('built CLI lazy-search startup', () => {
+  it('boots and disposes the shipped composition without a SQLite startup warning', async () => {
+    expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true)
+    expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true)
+    const rows = yaml.load(await readFile(configPath, 'utf8'), { schema: configSchema }) as ConfigRow[]
+    const searchRow = rows.find(row => row.id === 'session-query-sqlite')
+    expect(searchRow?.config?.openAt).toBe('first-search')
+
+    const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-'))
+    try {
+      const result = await runBuiltWeb(cwd)
+      expect(result.stdout).toMatch(/dsh web: http:\/\/127\.0\.0\.1:\d+/u)
+      expect(result.code).toBe(0)
+      expect(result.stderr).not.toMatch(/ExperimentalWarning: SQLite/u)
+    } finally {
+      await rm(cwd, { recursive: true, force: true })
+    }
+  }, 70_000)
+})
diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts
new file mode 100644
index 0000000000..89b383da85
--- /dev/null
+++ b/apps/web/tests/access-confirmation.e2e.ts
@@ -0,0 +1,104 @@
+// Web e2e scenario: every visible permission picker gates Full access behind
+// the same locale-aware, in-page risk confirmation. Zero model calls: the
+// scenario boots the shipped Web composition and exercises the real
+// permission projection, client command path, HTTP RPC, and pushed update.
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+/**
+ * connectFreshWorkspace twin over the product default Chinese locale (the
+ * shared helper's anchors assume the English page every other scenario
+ * boots; this scenario deliberately keeps zh, so the localized picker
+ * copy is the anchor set).
+ */
+async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise {
+  await page.getByRole('button', { name: '选择工作区' }).click()
+  await page.getByRole('menuitem', { name: '新建工作区' }).click()
+  const dialog = page.getByRole('dialog', { name: '新建工作区' })
+  await dialog.waitFor({ timeout: 10_000 })
+  await dialog.getByLabel('新工作区名称').fill(name)
+  await dialog.getByRole('button', { name: '创建工作区' }).click()
+  await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
+    .waitFor({ timeout: 15_000 })
+}
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
+const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const MODE = webSnapshotMode()
+
+describe('web e2e: Full access confirmation', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    // CI uses Playwright's pinned browser. A developer may point this one
+    // scenario at an installed Chromium when the matching browser download
+    // is temporarily unavailable.
+    const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH
+    browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
+    // Keep the product default Chinese locale: the golden pins the actual
+    // registered dictionary rather than a test-local translation callback.
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspaceZh(page)
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('requires acknowledgement before the composer picker can enable Full access', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation'))
+    const access = page.locator('button[aria-label^="访问模式"]').first()
+    await access.waitFor({ timeout: 10_000 })
+
+    // Normalize the starting preset through the real command path. The
+    // shipped web config may already start at Full access.
+    if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
+      await access.click()
+      await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
+      await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+        .toBe('访问模式,当前:Workspace Write')
+    }
+
+    await access.click()
+    await page.getByRole('menuitem', { name: 'Full access' }).click()
+    const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' })
+    await dialog.waitFor({ timeout: 10_000 })
+    const enable = dialog.getByRole('button', { name: '启用 Full access' })
+    expect(await enable.isDisabled()).toBe(true)
+
+    // The modal is in this page's body (not a native/new window) and escapes
+    // the sticky composer's stacking context.
+    expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true)
+    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+
+    await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
+    expect(await enable.isEnabled()).toBe(true)
+    await enable.click()
+    await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+      .toBe('访问模式,当前:Full access')
+    expect(await dialog.count()).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('keeps its snapshot inventory closed', async () => {
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+  })
+})
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index 0bac8a2eb8..6fa5aec1ab 100644
--- a/apps/web/tests/built-boot.snapshot.ts
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -111,6 +111,29 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
     expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
   }, { timeout: 10_000 })
 
+  // The write/edit turns render a real diff card through the assembled graph
+  // (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text.
+  // The write turn's `hello fixture\n` proves the terminator rule end to end: a
+  // trailing newline terminates its line, so the footer reads `+1` (not a
+  // phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so
+  // it is absent from textContent — assert on the line body and the footer.
+  const diffCards = [...document.querySelectorAll('[data-diff]')]
+  expect(diffCards.length).toBeGreaterThan(0)
+  const footers = diffCards.map(card => card.textContent ?? '')
+  expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
+
+  // The web render intent reaches the assembled boot graph: the fixture's
+  // web_search / web_fetch turns render their keyed WebRow cards, proving the
+  // registration, wire projection, and card rendering survive the real bundle
+  // path (not just the per-package src benches). The selector pins the KEYED
+  // WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute
+  // WebBlock draws — the generic fallback renders the same WebBlock, so a silent
+  // keyed-registration failure would still satisfy a bare `[data-web]` check.
+  await waitFor(() => {
+    expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull()
+    expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull()
+  }, { timeout: 10_000 })
+
   // Every bundle injected its plugin-owned style tag (the loader's CSS path).
   const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
     .map(style => style.getAttribute('data-plugin'))
diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts
index e0ad2d26e1..66bc9c6900 100644
--- a/apps/web/tests/cordis-tool-round.e2e.ts
+++ b/apps/web/tests/cordis-tool-round.e2e.ts
@@ -107,7 +107,8 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
 
     const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first()
     await mountRow.waitFor({ timeout: 10_000 })
-    await mountRow.locator('button[aria-expanded]').click()
+    // The whole summary row is the expand toggle (unified tool-row interaction).
+    await mountRow.locator('[aria-expanded]').first().click()
     await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 })
       .toContain(MOUNT_CODE)
 
diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts
index d4d684b1de..97ffefccf7 100644
--- a/apps/web/tests/lifecycle-chrome.e2e.ts
+++ b/apps/web/tests/lifecycle-chrome.e2e.ts
@@ -25,6 +25,8 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
+const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
+const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
 // Post-reload golden: the same settled conversation rebuilt purely from
 // persistence + history — byte-equal rendering is exactly the recovery claim.
 const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
@@ -56,6 +58,88 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     await scaffold?.close()
   })
 
+  it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
+    const launcher = page.getByRole('button', { name: 'Commands' })
+    await launcher.click()
+    const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+    await menu.waitFor({ timeout: 10_000 })
+    const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
+    expect(snapshot).toContain('text: Commands')
+    expect(snapshot).not.toContain('text: Skills')
+    expect(snapshot).not.toContain('text: Subagents')
+    const launchedBox = await menu.boundingBox()
+    await page.locator('textarea').first().press('Escape')
+    await expect.poll(() => menu.count()).toBe(0)
+    const input = page.locator('textarea').first()
+    await input.fill('/')
+    await menu.waitFor({ timeout: 10_000 })
+    const typedBox = await menu.boundingBox()
+    expect(launchedBox).not.toBeNull()
+    expect(typedBox).not.toBeNull()
+    expect(Math.abs(launchedBox!.x - typedBox!.x)).toBeLessThan(1)
+    expect(Math.abs(
+      launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
+    )).toBeLessThan(1)
+    await input.fill('')
+    await expect.poll(() => menu.count()).toBe(0)
+  })
+
+  it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
+    const activeScaffold = await launchWebScaffold()
+    const activePage = await newEnglishPage(browser)
+    const activeTripwire = watchConsole(activePage)
+    try {
+      await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
+      await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+      await connectFreshWorkspace(activePage)
+      const input = activePage.locator('textarea').first()
+      await activePage.getByRole('button', { name: 'Commands' }).click()
+      const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
+      await menu.waitFor({ timeout: 10_000 })
+      await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
+      await expect.poll(() => input.inputValue()).toBe('/plan ')
+      await input.press('Enter')
+      const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
+      await planButton.waitFor({ timeout: 10_000 })
+      const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
+      await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
+      const planStyle = await planButton.evaluate((element) => {
+        const probe = document.createElement('span')
+        probe.style.color = 'var(--dsw-alias-state-warn-label)'
+        probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
+        document.body.append(probe)
+        const actual = getComputedStyle(element)
+        const reference = getComputedStyle(probe)
+        const result = {
+          color: actual.color,
+          backgroundColor: actual.backgroundColor,
+          borderRadius: actual.borderRadius,
+          fontSize: actual.fontSize,
+          referenceColor: reference.color,
+          referenceBackgroundColor: reference.backgroundColor,
+        }
+        probe.remove()
+        return result
+      })
+      expect(planStyle.color).toBe(planStyle.referenceColor)
+      expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
+      expect(planStyle.borderRadius).toBe('999px')
+      expect(planStyle.fontSize).toBe('13px')
+      await planButton.click()
+      await expect.poll(() => planButton.count()).toBe(0)
+      expect(activeTripwire.pageErrors).toEqual([])
+      expect(activeTripwire.warnings).toEqual([])
+    } catch (error) {
+      await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined)
+      throw error
+    } finally {
+      await activePage.close()
+      await activeScaffold.close()
+    }
+  })
+
   it('sends the first prompt from the empty-state hero (all modes)', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
     if (MODE !== 'record') {
@@ -152,6 +236,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md', 'reloaded.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
+    ])
   })
 })
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index c563638e80..2a4107e128 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -221,8 +221,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     // only on change, so attempt count is invisible there).
     expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1)
     await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0)
-    // Golden of the recovered end-state: indistinguishable from a clean
-    // completion — retries are deliberately invisible in the transcript.
+    // Golden of the recovered end-state: the discarded partial stays absent,
+    // while the settled retry row remains as durable recovery context.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
     expect(tripwire.pageErrors).toEqual([])
diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts
index 12a2c362ab..8ffeb25ad1 100644
--- a/apps/web/tests/navigation-panes.e2e.ts
+++ b/apps/web/tests/navigation-panes.e2e.ts
@@ -23,6 +23,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
 const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
+const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
 const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const MODE = webSnapshotMode()
 const SEED_ID = 'navigation-panes-web-e2e'
@@ -95,39 +96,39 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read'])
   }, 400_000)
 
-  it.skipIf(MODE === 'record')('opens the seeded session and renders both turns from the log', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open'))
-    // Expand the collapsed group row, then open the revealed session row.
-    const groupRow = page.locator('[role="treeitem"]').first()
-    await groupRow.waitFor({ timeout: 15_000 })
-    await groupRow.click()
-    const sessionRow = page.locator('[role="treeitem"]').nth(1)
-    await sessionRow.waitFor({ timeout: 10_000 })
-    await sessionRow.click()
+  it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
+    const search = page.getByPlaceholder('Search name, keywords', { exact: false })
+    // The cold row has not been opened, so only the persisted log can satisfy
+    // this query. First search lazily reconciles the SQLite content index.
+    await search.fill('zzzqx-no-such-session')
+    await page.getByText('No matching sessions').waitFor({ timeout: 30_000 })
+    await expect.poll(
+      () => page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem').count(),
+      { timeout: 10_000 },
+    ).toBe(0)
+
+    await search.fill('WATERFALL')
+    const resultTree = page.getByRole('tree', { name: 'Search results' })
+    const result = resultTree.getByRole('treeitem')
+    await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1)
+    await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), {
+      timeout: 10_000,
+    }).toBeGreaterThanOrEqual(1)
+    const snapshot = (await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE)
+
+    await result.click()
+    // Search navigation addresses the session, not a specific event, and the
+    // query remains until the user explicitly clears it.
+    await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL')
     await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
     await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
-  }, 90_000)
-
-  it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
-    // Runs after the session is open: a cold summary carries no title (the
-    // sidebar shows the cwd basename), and the durable title lands with the
-    // attach subscription's baseline — which is itself worth pinning: search
-    // matches the title the user sees, not a hidden cold field.
-    const search = page.getByPlaceholder('Search name, keywords', { exact: false })
-    await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
-    // Negative: a garbage query empties the tree (group rows hide too).
-    await search.fill('zzzqx-no-such-session')
-    await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0)
-    // Positive: a title word narrows to the matched session + its group,
-    // force-expanded by search mode (case-insensitive client-side filter).
-    await search.fill('navscenario')
-    await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
-    // Clear restores the unfiltered tree.
     await page.getByRole('button', { name: 'Clear search' }).click()
     await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
     await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
-  }, 60_000)
+  }, 90_000)
 
   it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
@@ -180,11 +181,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await bashRow.waitFor({ timeout: 15_000 })
     const frame = page.locator('[style*="grid-template-columns"]').first()
     expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
+    // The row click is the card's expand toggle (unified tool-row
+    // interaction); it must not drive layout geometry either way.
     await bashRow.click()
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
     // The card's own controls are outside the summary row and must not open
-    // details either — the terminal card is read in place.
-    await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
+    // details either — the expanded terminal card is read in place.
+    await page.locator('[data-sample="bash-global"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
     // Read summaries are host-open file links; they also must not open details.
     const fileLink = page.locator('[data-variant="read"] button').first()
@@ -196,10 +199,14 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
   it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
     await page.getByRole('tab', { name: 'Chat' }).click()
-    // The card is resident in the keyed bash row (no expand gesture): the
-    // recorded command's own output sits in the message flow, derived from the
-    // logged call/result presentations alone.
-    const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [data-terminal]').first()
+    // The card is expand-gated behind the whole-row toggle (the unified
+    // tool-row interaction): open it if a previous case left it collapsed.
+    // Expanded, the recorded command's own output sits in the message flow,
+    // derived from the logged call/result presentations alone.
+    const bashRow = page.locator('[data-sample="bash-global"]').first()
+    await bashRow.waitFor({ timeout: 15_000 })
+    if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
+    const card = page.locator('[data-sample="bash-global"] ~ div [data-terminal]').first()
     await card.waitFor({ timeout: 15_000 })
     // Real layout, not jsdom's stub (which computes no geometry at all):
     // squeeze the output pane below its content width and the line must keep
@@ -279,7 +286,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     expect(slotErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'seed.jsonl', 'trajectory.expected.md', 'terminal-card.expected.md',
+      'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
+      'terminal-card.expected.md',
     ])
   })
 })
diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts
index 9d577dff54..e083ed8c06 100644
--- a/apps/web/tests/queue-actions.e2e.ts
+++ b/apps/web/tests/queue-actions.e2e.ts
@@ -96,6 +96,26 @@ describe('web e2e: queue row actions', () => {
       { timeout: 10_000 },
     ).toBe(2)
 
+    await page.setViewportSize({ width: 640, height: 1000 })
+    const queueBox = await page.locator('[data-queue-dock]').boundingBox()
+    const composerBox = await page.locator('[data-composer-card]').boundingBox()
+    expect(queueBox).not.toBeNull()
+    expect(composerBox).not.toBeNull()
+    expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x)
+    expect(queueBox!.x + queueBox!.width)
+      .toBeLessThanOrEqual(composerBox!.x + composerBox!.width)
+    const queueLeftInset = queueBox!.x - composerBox!.x
+    const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width
+    const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => {
+      const style = getComputedStyle(element)
+      return {
+        dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')),
+      }
+    })
+    expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1)
+    expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1)
+    await page.setViewportSize({ width: 1680, height: 1000 })
+
     const editRow = page.getByText(EDIT, { exact: true }).locator('..')
     await editRow.getByRole('button', { name: 'Edit queued message' }).click()
     const editor = page.getByRole('textbox', { name: 'Edit queued message' })
diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts
index da0d3ba6bb..637f8299ca 100644
--- a/apps/web/tests/scaffold.ts
+++ b/apps/web/tests/scaffold.ts
@@ -200,6 +200,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise {
     // where neither half repeats the other (the dispatched `/` and its
     // argument stay out of the title, and the settlement text never restates
     // the command's own name).
-    await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click()
+    await page.getByRole('button', { name: 'Access mode, current: Full access' }).click()
     await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
     await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 })
     // Scoped to the row itself, so unrelated page text that happens to read
diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts
index d2b2d32f21..8bcb8da23a 100644
--- a/apps/web/tests/smoke-real.e2e.ts
+++ b/apps/web/tests/smoke-real.e2e.ts
@@ -267,6 +267,98 @@ describe('dsh web keyless CLI smoke', () => {
     }
   })
 
+  it('retries a partial transport failure through the shipped Web composition', async () => {
+    requireDist()
+    const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-retry-'))
+    const promptMarker = 'WEB_RETRY_REQUEST'
+    const recoveredMarker = 'WEB_RETRY_RECOVERED'
+    let mainAttempts = 0
+    const provider = createServer((request, response) => {
+      let body = ''
+      request.setEncoding('utf8')
+      request.on('data', (chunk: string) => { body += chunk })
+      request.on('end', () => {
+        const parsed = JSON.parse(body) as { max_tokens?: number; messages?: unknown[] }
+        const titleRequest = parsed.max_tokens === 64
+        const mainRequest = !titleRequest && body.includes(promptMarker)
+        response.writeHead(200, { 'content-type': 'text/event-stream' })
+        if (!mainRequest) {
+          response.end([
+            'data: {"choices":[{"delta":{"content":"Web retry title"}}]}',
+            'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}',
+            'data: [DONE]',
+            '',
+          ].join('\n\n'))
+          return
+        }
+        mainAttempts++
+        if (mainAttempts === 1) {
+          response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
+          setTimeout(() => { response.destroy() }, 20)
+          return
+        }
+        response.end([
+          `data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`,
+          'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
+          'data: [DONE]',
+          '',
+        ].join('\n\n'))
+      })
+    })
+    await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve))
+    const address = provider.address()
+    if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
+    const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
+    const child = spawn(
+      process.execPath,
+      ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
+      {
+        cwd: workspace,
+        env: {
+          ...process.env,
+          DEEPSEEK_API_KEY: 'keyless-web-retry',
+          DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
+          DSH_HOME: join(workspace, '.dsh'),
+          TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
+        },
+        stdio: ['ignore', 'pipe', 'pipe'],
+      },
+    )
+    try {
+      const baseUrl = await waitForReadyLine(child)
+      const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
+      await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
+        sessionId: created.sessionId,
+        mode: 'queue',
+        content: [{ type: 'text', text: promptMarker }],
+      })
+      let page: HistoryPage | undefined
+      await expect.poll(async () => {
+        page = await history(baseUrl, created.sessionId)
+        return hasAssistantMarker(page, recoveredMarker)
+      }, { timeout: 20_000 }).toBe(true)
+      if (page === undefined) throw new Error('retry history was not observed')
+      const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event
+      expect(mainAttempts).toBe(2)
+      expect(retry?.data).toMatchObject({
+        turn: 1,
+        step: 1,
+        retry: 1,
+        maxRetries: 2,
+        failure: { code: 'TRANSPORT' },
+      })
+      expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED')
+    } finally {
+      const closed = child.exitCode === null
+        ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) })
+        : Promise.resolve()
+      if (child.exitCode === null) child.kill('SIGTERM')
+      await closed
+      await new Promise(resolveClose => provider.close(() => { resolveClose() }))
+      rmSync(workspace, { recursive: true, force: true })
+    }
+  }, 30_000)
+
   it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => {
     requireDist()
     const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-code-mode-'))
diff --git a/apps/web/tests/snapshots/access-confirmation/ui.expected.md b/apps/web/tests/snapshots/access-confirmation/ui.expected.md
new file mode 100644
index 0000000000..1287e6e565
--- /dev/null
+++ b/apps/web/tests/snapshots/access-confirmation/ui.expected.md
@@ -0,0 +1,10 @@
+- dialog "确认启用 Full access?":
+  - heading "确认启用 Full access?" [level=2]
+  - button "Close":
+    - img
+  - img
+  - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
+  - checkbox "我已了解风险,并愿意继续"
+  - text: 我已了解风险,并愿意继续
+  - button "取消"
+  - button "启用 Full access" [disabled]
diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
index afc722db14..5d1979eadf 100644
--- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md
+++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
@@ -15,13 +15,15 @@
   - img
   - img
   - text: "Think The user wants me to write a single `run_code` program that:"
-- button:
+- button "Code Run bash echo and catch missing file read":
   - img
   - img
-- text: Code Run bash echo and catch missing file read
+  - text: Code Run bash echo and catch missing file read
 - img
-- text: Bash Echo CODE_ROUND_OK Read
-- button "missing.txt"
+- text: Bash Echo CODE_ROUND_OK
+- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
+  - img
+  - text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"
 - button "Think The program ran successfully. Let me now reply DONE as instructed.":
   - img
   - img
@@ -33,10 +35,9 @@
   - img
 - text: {{clock}}
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
index 297915e52f..3ccbdfb332 100644
--- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
+++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
@@ -15,27 +15,30 @@
   - img
   - img
   - text: "Think The user wants me to:"
-- button:
+- button "Inspect temporary":
   - img
   - img
-- text: Inspect temporary
+  - text: Inspect temporary
 - 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."':
   - img
   - img
   - text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
-- button [expanded]:
+- 'button "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }" [expanded]':
   - img
-- text: Mount temporary Plugin typescript
+  - text: "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }"
+- text: typescript
 - button "Copy"
 - code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
+- text: OUT Temporary Plugin dyn-1 is running (plugin "snapshot-noop"; available until unmounted or DSH restarts).
+- button "Inspect"
 - 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."':
   - img
   - img
   - text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
-- button:
+- button "Unmount temporary Plugin dyn-1":
   - img
   - img
-- text: Unmount temporary Plugin dyn-1
+  - text: Unmount temporary Plugin dyn-1
 - button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.":
   - img
   - img
@@ -47,10 +50,9 @@
   - img
 - text: {{clock}}
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
index 089a9e8efe..039ecc99ea 100644
--- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
+++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
@@ -15,10 +15,10 @@
   - img
   - img
   - text: Think The user wants me to run a simple bash command and reply with "DONE".
-- img
-- text: Bash Echo the test string Done workspace echo WEB_E2E_OK
-- button "Copy"
-- text: WEB_E2E_OK
+- button "Bash Echo the test string":
+  - img
+  - img
+  - text: Bash Echo the test string
 - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
   - img
   - img
@@ -30,10 +30,9 @@
   - img
 - text: {{clock}}
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md
new file mode 100644
index 0000000000..47ba98cf05
--- /dev/null
+++ b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md
@@ -0,0 +1,6 @@
+- listbox "Trigger suggestions":
+  - text: Commands
+  - option "goal set or view the goal for a long-running task" [selected]
+  - option "permission Switch the permission preset (sandbox mode + approval policy)"
+  - option "plan Enter or leave plan mode"
+  - option "model Select the model for this conversation"
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
index 70424a4c8c..7024719a3a 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
@@ -26,10 +26,9 @@
   - text: workspace
   - img
 - textbox "Describe what you want to build"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
new file mode 100644
index 0000000000..15bee7afe4
--- /dev/null
+++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
@@ -0,0 +1,39 @@
+- button "New session"
+- button "Collapse sidebar":
+  - img
+- button "New session":
+  - img
+  - text: New Session
+- text: Workspaces
+- button "Group by":
+  - img
+- button "Create workspace":
+  - img
+- button "Search sessions":
+  - img
+- textbox "Search name, keywords..."
+- tree "Sessions":
+  - treeitem "workspace 1 session" [expanded]:
+    - img
+    - text: workspace 1 session
+  - treeitem "New Session now" [selected]
+- button "Settings":
+  - img
+  - text: Settings
+- text: Let's start building
+- button "Choose workspace":
+  - img
+  - text: workspace
+  - img
+- textbox "Describe what you want to build"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Full access"': Full access
+- button "Plan mode on, press to turn off": Plan
+- button "Select model, current deepseek-v4-flash":
+  - text: deepseek-v4-flash
+  - img
+- button "Send message" [disabled]
+- text: Details
+- button "Close details"
+- text: Click a tool row in the message flow to view its details
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
index 52e43a54f7..113f81f9eb 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
@@ -22,10 +22,9 @@
   - img
 - text: {{clock}}
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
index eab492b96e..eb3742eb34 100644
--- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
@@ -19,10 +19,9 @@
   - img
 - text: {{clock}}
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
index b214ad80d5..9dcca575de 100644
--- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
@@ -12,10 +12,9 @@
 - button "Edit":
   - img
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md
index 572d77b22e..4ed6a10c6f 100644
--- a/apps/web/tests/snapshots/live-interactions/retry.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md
@@ -11,6 +11,8 @@
   - img
 - button "Edit":
   - img
+- group:
+  - status: Retried model request (1/2) · {{duration}}
 - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
   - img
   - img
@@ -22,10 +24,9 @@
   - img
 - text: {{clock}}
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md
index 098fa20807..9aed20cfce 100644
--- a/apps/web/tests/snapshots/message-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/message-actions/ui.expected.md
@@ -16,12 +16,16 @@
   - img
   - img
   - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
-- img
-- text: Read
-- button "a.txt"
-- img
-- text: Read
-- button "b.txt"
+- button "Read a.txt":
+  - img
+  - img
+  - text: Read
+  - button "a.txt"
+- button "Read b.txt":
+  - img
+  - img
+  - text: Read
+  - button "b.txt"
 - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
   - img
   - img
@@ -33,10 +37,9 @@
   - img
 - text: 7/25 {{clock}}
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current deepseek-v4-flash":
   - text: deepseek-v4-flash
   - img
diff --git a/apps/web/tests/snapshots/navigation-panes/search-results.expected.md b/apps/web/tests/snapshots/navigation-panes/search-results.expected.md
new file mode 100644
index 0000000000..49de115594
--- /dev/null
+++ b/apps/web/tests/snapshots/navigation-panes/search-results.expected.md
@@ -0,0 +1,2 @@
+- tree "Search results":
+  - 'treeitem "{{workspace}} {{workspace}} ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"'
diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md
index 0aa340fa77..a81ed0ecd4 100644
--- a/apps/web/tests/snapshots/plan-review/approved.expected.md
+++ b/apps/web/tests/snapshots/plan-review/approved.expected.md
@@ -20,10 +20,10 @@
   - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with
   - code: exit_plan_mode
   - text: .
-- button:
+- 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"':
   - img
   - img
-- text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"
+  - text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"
 - 'button "Think The plan was approved. The user''s last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."':
   - img
   - img
@@ -35,10 +35,9 @@
   - img
 - text: {{clock}}
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md
index 20603ed2f2..03c5d84005 100644
--- a/apps/web/tests/snapshots/question-composer/answered.expected.md
+++ b/apps/web/tests/snapshots/question-composer/answered.expected.md
@@ -15,10 +15,10 @@
   - img
   - img
   - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.
-- button:
+- button "Ask question 1/1 answered":
   - img
   - img
-- text: Ask question 1/1 answered
+  - text: Ask question 1/1 answered
 - button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
   - img
   - img
@@ -30,10 +30,9 @@
   - img
 - text: {{clock}}
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
index 0b92df9a16..f09469c98c 100644
--- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
@@ -14,10 +14,9 @@
 - paragraph: partial
 - button "2 queued messages"
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md
index 5ced1f6f4e..cd211013c8 100644
--- a/apps/web/tests/snapshots/queue-actions/editing.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md
@@ -27,10 +27,9 @@
     - button "Cancel editing":
       - img
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md
index 343ecc0fe2..197e1cd622 100644
--- a/apps/web/tests/snapshots/queue-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md
@@ -20,10 +20,9 @@
     - button "Remove queued message":
       - img
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md
index d11a0cef6d..4712180e92 100644
--- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md
@@ -15,12 +15,16 @@
   - img
   - img
   - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
-- img
-- text: Read
-- button "a.txt"
-- img
-- text: Read
-- button "b.txt"
+- button "Read a.txt":
+  - img
+  - img
+  - text: Read
+  - button "a.txt"
+- button "Read b.txt":
+  - img
+  - img
+  - text: Read
+  - button "b.txt"
 - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
   - img
   - img
@@ -41,10 +45,9 @@
 - img
 - text: permission preset workspace-write
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
 - 'button "Access mode, current: Workspace Write"': Workspace Write
-- button "Plan mode off, press to turn on": Plan off
 - button "Select model, current deepseek-v4-flash":
   - text: deepseek-v4-flash
   - img
diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md
index 8044e9e4ca..8b8a789fd2 100644
--- a/apps/web/tests/snapshots/seeded-history/ui.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md
@@ -15,12 +15,16 @@
   - img
   - img
   - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
-- img
-- text: Read
-- button "a.txt"
-- img
-- text: Read
-- button "b.txt"
+- button "Read a.txt":
+  - img
+  - img
+  - text: Read
+  - button "a.txt"
+- button "Read b.txt":
+  - img
+  - img
+  - text: Read
+  - button "b.txt"
 - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
   - img
   - img
@@ -39,10 +43,9 @@
   - img
   - text: Context injection
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current deepseek-v4-flash":
   - text: deepseek-v4-flash
   - img
diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md
index 9cac976f9e..31c5b2b1dc 100644
--- a/apps/web/tests/snapshots/steering/mid-steer.expected.md
+++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md
@@ -15,10 +15,10 @@
   - img
   - img
   - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
-- button:
+- button "Ask question waiting":
   - img
   - img
-- text: Ask question waiting
+  - text: Ask question waiting
 - region "Ready to continue?":
   - text: Checkpoint
   - heading "Ready to continue?" [level=2]
diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md
index aa51d2c489..ba2adad29f 100644
--- a/apps/web/tests/snapshots/steering/settled.expected.md
+++ b/apps/web/tests/snapshots/steering/settled.expected.md
@@ -15,10 +15,11 @@
   - img
   - img
   - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
-- button:
+- button "Ask question 1/1 answered":
   - img
   - img
-- text: "Ask question 1/1 answered Interjection Interjection: include the word BANANA in your final reply."
+  - text: Ask question 1/1 answered
+- text: "Interjection Interjection: include the word BANANA in your final reply."
 - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
   - img
   - img
@@ -30,10 +31,9 @@
   - img
 - text: {{clock}}
 - textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index f5774e1545..d7e48e4e6b 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -43,7 +43,8 @@
     "tests/cordis-tool-round.e2e.ts",
     "tests/message-actions.e2e.ts",
     "tests/queue-actions.e2e.ts",
-    "tests/skill-invocation-policy.e2e.ts"
+    "tests/skill-invocation-policy.e2e.ts",
+    "tests/access-confirmation.e2e.ts"
   ],
   "references": [
     {
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 31ec477399..6285fe2296 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -776,7 +776,7 @@ Requires: `agents`
 export type Config = Readonly>
 ```
 
-Source: [`packages/llm/llm-retry/src/index.ts:45`](../packages/llm/llm-retry/src/index.ts)
+Source: [`packages/llm/llm-retry/src/index.ts:47`](../packages/llm/llm-retry/src/index.ts)
 
 ## `@deepseek-ai/dsh-lsp-local`
 
@@ -1144,11 +1144,13 @@ Requires: `sessions`
 /** Combined session-query configuration backed by SQLite full-text search. */
 export interface Config extends SessionQueryConfig {
   /**
-   * Dedicated derived-index path; `:memory:` is supported for tests. Missing
-   * directories and database files are created owner-only on POSIX filesystems;
-   * existing modes are preserved.
+   * Dedicated derived-index path; `:memory:` is supported for ephemeral
+   * indexes. Missing directories and database files are created owner-only on
+   * POSIX filesystems; existing modes are preserved.
    */
   path: string
+  /** Open the SQLite module and handle at service activation or the first search. Defaults to `startup`. */
+  openAt?: OpenAt
   /** SQLite journal mode. Defaults to `wal`. */
   journalMode?: JournalMode
   /** Page size when a request omits `limit`. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 20. */
@@ -1161,13 +1163,16 @@ export interface Config extends SessionQueryConfig {
   persistedInspectConcurrency?: number
 }
 
+/** SQLite module/handle opening phase. */
+export type OpenAt = 'startup' | 'first-search'
+
 /** Supported SQLite journal modes. */
 export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
 ```
 
 Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts)
 
-Source: [`packages/session-query/session-query-sqlite/src/index.ts:86`](../packages/session-query/session-query-sqlite/src/index.ts)
+Source: [`packages/session-query/session-query-sqlite/src/index.ts:89`](../packages/session-query/session-query-sqlite/src/index.ts)
 
 ## `@deepseek-ai/dsh-session-reference`
 
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 2c3c2a1897..bb1085a9b8 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -328,7 +328,7 @@ Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-ba
 
 ## `ctx.clientModuleHost` — `ClientModuleHostService`
 
-The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot sweep reports it).
+The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
 
 ```ts cordis-catalog
 /**
@@ -368,7 +368,7 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
 onGraphChanged(listener: () => void): () => void
 ```
 
-Source: [`packages/client/modules/src/index.ts:143`](../../packages/client/modules/src/index.ts)
+Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts)
 
 ## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
 
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 7913567237..6f0162ae68 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -310,6 +310,7 @@ flowchart TD
   pkg_client_test_runtime --> pkg_client_runtime
   pkg_client_test_runtime --> pkg_client_ui_slots
   pkg_client_test_runtime --> pkg_client_web_react
+  pkg_client_test_runtime --> pkg_host_apiproxy
   pkg_client_test_runtime --> pkg_invariants
   pkg_client_ui_settings --> pkg_client_runtime
   pkg_client_ui_settings --> pkg_client_ui_primitives
@@ -825,6 +826,7 @@ flowchart TD
   pkg_tool_ask_user --> pkg_invariants
   pkg_tool_ask_user --> pkg_tools
   pkg_tool_ask_user --> pkg_user_interaction
+  pkg_client_ui_permission --> pkg_client_locale
   pkg_client_ui_permission --> pkg_client_runtime
   pkg_client_ui_permission --> pkg_client_ui_command
   pkg_client_ui_permission --> pkg_client_ui_slash
@@ -935,6 +937,7 @@ flowchart TD
   pkg_client_ui_plan --> pkg_client_locale
   pkg_client_ui_plan --> pkg_client_runtime
   pkg_client_ui_plan --> pkg_client_ui_conversation
+  pkg_client_ui_plan --> pkg_client_ui_primitives
   pkg_client_ui_plan --> pkg_client_ui_slots
   pkg_client_ui_plan --> pkg_invariants
   pkg_client_ui_plan --> pkg_plan_mode
@@ -1061,7 +1064,7 @@ flowchart TD
 | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
 | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
-| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
+| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
 | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
@@ -1177,7 +1180,7 @@ flowchart TD
 | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) |
 | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
+| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
@@ -1192,7 +1195,7 @@ flowchart TD
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
 | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
+| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
 | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
 | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
diff --git a/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs b/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs
new file mode 100644
index 0000000000..16e5858045
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs
@@ -0,0 +1,6 @@
+/** Fail activation with a deterministic stack so the user-visible startup diagnostic is snapshot-stable. */
+export function apply() {
+  const failure = new Error('startup activation snapshot failure')
+  failure.stack = 'Error: startup activation snapshot failure\n    at activation-error-fixture'
+  throw failure
+}
diff --git a/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml b/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml
new file mode 100644
index 0000000000..2738e4a924
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml
@@ -0,0 +1,2 @@
+- id: activation-error
+  name: ./activation-error.mjs
diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml
index d9cc454bfb..d1851ac7c9 100644
--- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml
+++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml
@@ -8,6 +8,7 @@
 - id: telemetry-redact-rule
   name: './telemetry-redact-rule.ts'
 
+# Managed child-process groups required by the bash executor.
 - id: subprocess
   name: '@deepseek-ai/dsh-subprocess-local'
 
diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts
index 4217f24228..8b48165a83 100644
--- a/examples/headless-agent/tests/headless.snapshot.ts
+++ b/examples/headless-agent/tests/headless.snapshot.ts
@@ -33,6 +33,8 @@ const credentialsScenarioDir = join(snapshotsDir, 'missing-credential')
 const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url))
 const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
 const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
+const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url))
+const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt')
 const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
 const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
 const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url))
@@ -167,6 +169,20 @@ async function persistedLogs(cwd: string): Promise {
 }
 
 describe('headless stream-json snapshots', () => {
+  it('prints the original Loader activation error through the assembled one-shot app', async () => {
+    const result = await runLoaderSmoke({
+      label: 'headless startup activation error snapshot',
+      tempDirPrefix: 'headless-snapshot-startup-error-',
+      binScript,
+      configPath: startupFailureConfigPath,
+      binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'],
+      tsconfigPath,
+      expectedExitCode: 1,
+    })
+    expect(result.stdout).toBe('')
+    await expect(result.stderr).toMatchFileSnapshot(startupFailureExpected)
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
   it('retries a transient provider failure through the one-shot app', async () => {
     const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry')
     const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl')
diff --git a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt
new file mode 100644
index 0000000000..5896d03464
--- /dev/null
+++ b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt
@@ -0,0 +1,3 @@
+dsh-cli-demo: dsh-cli-demo: 1 entry did not activate
+./activation-error.mjs: Error: startup activation snapshot failure
+    at activation-error-fixture
diff --git a/examples/web-cordis/cordis.yml b/examples/web-cordis/cordis.yml
index 4cd96e396f..ff857643f4 100644
--- a/examples/web-cordis/cordis.yml
+++ b/examples/web-cordis/cordis.yml
@@ -12,7 +12,10 @@
   config:
     host: 127.0.0.1
     port: 3081
-    distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname"
+    # Plain concatenation, not URL.pathname: a cwd with spaces
+    # percent-encodes through the URL round-trip and the encoded
+    # path never resolves.
+    distIndex: !!js "process.cwd() + '/apps/web/dist/index.html'"
 
 - insert:
     - id: tool-cordis
diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml
index 70657d55d7..974e3014d6 100644
--- a/packages/client/connection/README.i18n.yaml
+++ b/packages/client/connection/README.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/connection/README.md
-README.md: d2fda9f15125915594259e01e5b153609ceb21bb
-README.zh.md: 669ae760693b4d98ee873ee5fe323554f58e7ca5
+README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
+README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45
diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md
index d2fda9f151..c8b7c4787c 100644
--- a/packages/client/connection/README.md
+++ b/packages/client/connection/README.md
@@ -10,7 +10,7 @@ The node half guards every request under `/api` before bridging (`src/api-reques
 
 ## Keyless fixture
 
-Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
+Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
 
 ## Model Experience
 
diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md
index 669ae76069..693420183f 100644
--- a/packages/client/connection/README.zh.md
+++ b/packages/client/connection/README.zh.md
@@ -10,7 +10,7 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
 
 ## 无密钥 fixture
 
-任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。
+任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
 
 ## 模型体验
 
diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts
index cc9d2a1eb9..ae47eb9e89 100644
--- a/packages/client/connection/src/client/api.ts
+++ b/packages/client/connection/src/client/api.ts
@@ -1,12 +1,12 @@
 // Central contract re-export point: every contract import inside
 // web-runtime goes through this single file.
-// Types are type-only imports from the apiproxy api/ layer (zero Node deps, browser-safe);
-// the only runtime values are the RpcId constructor and the AbstractApiClient seam.
+// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
+// (zero Node deps, browser-safe); AbstractApiClient is the client seam.
 // NEVER import the package root: it drags bootHost/cordis into the browser bundle.
 // The ./api and ./client subpath exports are the browser-safe channels added for this.
 
 export type {
-  ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
+  ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
   ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
   DirectoryEntry, DirectoryListing,
   WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -25,7 +25,11 @@ export type {
 // transportError moved down to the apiproxy api layer (it belongs beside
 // RpcResult, its subject); re-exported here so connection consumers keep one
 // contract entry point.
-export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
+export {
+  RpcId,
+  SESSION_SEARCH_RESULT_LIMIT,
+  transportError,
+} from '@deepseek-ai/dsh-host-apiproxy/api'
 export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
 export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
 export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 068fdc8307..45445cc9cf 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -26,13 +26,14 @@ import type {
 // Type-only: the brand constructor is host-side; the fixture casts at its
 // wire-fabrication boundary (the schema layer's one-cast-point posture).
 import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
+import { foldSurface } from '@deepseek-ai/dsh-session/surface'
 import type {
   ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
   ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
   ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
 } from './api.ts'
 import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
-import { AbstractApiClient, RpcId } from './api.ts'
+import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
 
 /** The fake carrier mints like a real one (business code never mints). */
 function rpcRequest

(payload: P): RpcRequest

{ @@ -136,6 +137,44 @@ const TERMINAL_EXIT_STATUS: Record, 'card' | 'kind'> = { + answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.', + sources: [ + { + url: 'https://github.com/deepseek-ai/deepseek-harness', + title: 'DeepSeek Harness — plugin-based agent harness', + snippet: 'Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.', + publishedAt: '2026-07-01', + }, + { + url: 'https://www.deepseek.com/blog/harness-architecture', + snippet: 'The capability-seam pattern splits each capability into interface, implementation, and consumer packages.', + }, + { + url: 'https://docs.deepseek.com/harness/plugins', + title: 'Writing a harness plugin', + publishedAt: '2026-06-15', + }, + ], + truncated: true, +} + +/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */ +const WEB_FETCH_RESULT: Omit, 'card' | 'kind'> = { + url: 'https://www.deepseek.com/blog/harness-architecture', + statusCode: 200, + truncated: false, +} + const DEEPSEEK_REASONING = { efforts: [ { id: 'off', name: 'Off' }, @@ -261,6 +300,13 @@ function buildAlphaLog(): SessionEvent[] { toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑') toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入') + // Turn 67: a multi-hunk edit — two scattered replacements in one file. Named + // `edit` so it lands on the keyed FileMutationRow (the resident diff card the + // single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker + // the presenter reads to emit the two-hunk sample: the card draws one path + // header, the first hunk, a `⋯` gap, then the second (the same-file + // second-hunk arm turns 62/63 cannot reach). + toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑') // Turn 64: one run_code turn with three logged sub-dispatches — the Code // Mode acceptance surface (parent code row + nested native-identical rows, // including an isError sub-call and a bash sub-call that must hit the same @@ -325,8 +371,20 @@ function buildAlphaLog(): SessionEvent[] { // strip empty and take the todo surfaces' own coverage with it. toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) + // Turns 66-67: the web render intent — a web_search whose result view carries + // structured sources plus an answer (the citation list, one source lacking a + // title so its hostname labels the link, the capped indicator on), and a + // web_fetch whose result view carries the fetched URL and its HTTP status. + // Both keep a generic pending call view and add the `web` card only at + // result time, which is the contract's result-only web shape. Named after + // the real tools so they hit the keyed WebRow registration. Ordered BEFORE + // the todo turn for the same reason turn 65 is: the standing plan retires at + // the next turn/start, so a turn after it would empty the dock's plan strip. + toolTurn(66, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') + toolTurn(67, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') + const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). @@ -362,9 +420,33 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }], } case 'edit': - return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args } + // The multi-hunk sample (turn 67) is keyed on its file_path, so the two + // scattered hunks share one path header and the card draws the `⋯` gap. + if (str(args.file_path) === 'src/config.ts') { + return { + card: 'diff', title: `Edit ${str(args.file_path)}`, + diffs: [ + { path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' }, + { path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' }, + ], + } + } + return { + card: 'diff', title: `Edit ${str(args.file_path)}`, + diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }], + } case 'write': - return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args } + return { + card: 'diff', title: `Write ${str(args.file_path)}`, + diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }], + } + // The web tools keep a GENERIC pending card and add the `web` result card + // only at result time (the contract's result-only web shape); their pending + // kind matches the result kind so a call and its result read as one category. + case 'web_search': + return { card: 'generic', title: `Search ${str(args.query)}`, kind: 'search', rawInput: args } + case 'web_fetch': + return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args } default: return undefined // echo et al: the documented no-view fallback path } @@ -373,6 +455,17 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined { const call = presentCall(name, argsRaw) if (call === undefined) return undefined + // The web tools keep a generic pending card, so their result card is chosen + // by tool name rather than by the pending card tag: the structured `web` card + // the frontend consumes. The view carries no `content` copy (per the contract + // and the web-result-card note); a capability-less UI falls back to the raw + // `tool/result` content, which this fixture emits from `resultText`. + if (name === 'web_search') { + return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT } + } + if (name === 'web_fetch') { + return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT } + } switch (call.card) { case 'terminal': // The sample's own exit status, authored beside it: re-parsing the @@ -575,6 +668,144 @@ function pageOf( return { events, hasMore: start > 0 } } +/** Fixture mirror of first-party message extraction used by session-query. */ +function searchBlockText(block: ContentBlock): string[] { + switch (block.type) { + case 'text': + return [block.text] + case 'reasoning': + return [] + case 'tool-call': + return [block.name, block.arguments] + case 'tool-result': + return block.content.flatMap(searchBlockText) + default: + return [] + } +} + +/** One current-surface user/assistant/steering document, if searchable. */ +function searchEventText(event: SessionEvent): string { + const content = event.type === 'user/message' + ? event.data.content + : event.type === 'assistant/message' || event.type === 'steering/message' + ? event.data.message.content + : undefined + if (content === undefined) return '' + return content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n') +} + +interface FixtureSearchToken { + value: string + /** Inclusive code-point offset in the whitespace-normalized display text. */ + start: number + /** Exclusive code-point offset in the whitespace-normalized display text. */ + end: number +} + +/** + * Browser-safe approximation of SQLite FTS5 unicode61 token boundaries. + * Keeping phrase matching token-based prevents the development fixture from + * promising arbitrary within-token substring behavior that production lacks. + */ +function searchTokenSpans(value: string): { text: string; tokens: FixtureSearchToken[] } { + const text = value.replace(/\s+/gu, ' ').trim() + const characters = Array.from(text) + const tokens: FixtureSearchToken[] = [] + let start: number | undefined + let raw = '' + const flush = (end: number): void => { + if (start !== undefined) { + const folded = raw.normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase() + if (folded !== '') tokens.push({ value: folded, start, end }) + } + start = undefined + raw = '' + } + for (let index = 0; index < characters.length; index++) { + const character = characters[index] as string + const tokenBase = character.normalize('NFD').replace(/\p{M}+/gu, '') + if (tokenBase === '') { + if (start !== undefined) raw += character + continue + } + if (/^[\p{L}\p{N}\p{Co}]+$/u.test(tokenBase)) { + start ??= index + raw += character + } else { + flush(index) + } + } + flush(characters.length) + return { text, tokens } +} + +interface FixturePhraseMatch { + count: number + start: number + end: number +} + +/** Count exact contiguous token-phrase occurrences and retain the first display span. */ +function phraseMatch(document: readonly FixtureSearchToken[], phrase: readonly string[]): FixturePhraseMatch { + if (phrase.length === 0 || phrase.length > document.length) return { count: 0, start: 0, end: 0 } + let count = 0 + let firstStart = 0 + let firstEnd = 0 + for (let start = 0; start <= document.length - phrase.length; start++) { + if (!phrase.every((token, offset) => document[start + offset]?.value === token)) continue + count++ + if (count === 1) { + firstStart = document[start]?.start ?? 0 + firstEnd = document[start + phrase.length - 1]?.end ?? firstStart + } + } + return { count, start: firstStart, end: firstEnd } +} + +/** Match-centered fixture excerpt, bounded by Unicode code points for the sidebar. */ +function searchSnippet(value: string, matchStart: number, matchEnd: number): string { + const characters = Array.from(value) + if (characters.length <= 120) return value + const boundedStart = Math.min(Math.max(0, matchStart), characters.length - 1) + const boundedEnd = Math.min( + characters.length, + Math.max(boundedStart + 1, matchEnd), + ) + const center = Math.floor((boundedStart + boundedEnd) / 2) + let start = Math.min( + characters.length - 118, + Math.max(0, center - Math.floor(118 / 2)), + ) + let end = start + 118 + if (start === 0) { + end = 119 + } else if (end === characters.length) { + start = characters.length - 119 + } + return `${start > 0 ? '…' : ''}${characters.slice(start, end).join('')}${end < characters.length ? '…' : ''}` +} + +interface FixtureSearchCandidate { + sessionId: SessionId + seq: number + time: number + text: string + matchCount: number + matchStart: number + matchEnd: number + documentLength: number +} + +/** Mirrors `packages/session-query/session-query-sqlite/src/index.ts`; update both together. */ +function compareSearchCandidates(a: FixtureSearchCandidate, b: FixtureSearchCandidate): number { + if (a.matchCount !== b.matchCount) return b.matchCount - a.matchCount + if (a.documentLength !== b.documentLength) return a.documentLength - b.documentLength + if (a.time !== b.time) return b.time - a.time + if (a.sessionId !== b.sessionId) return a.sessionId < b.sessionId ? -1 : 1 + return b.seq - a.seq +} + /** * Current plan projection over the full log (host parallel: latest todo/write * with no later turn/start; a new turn retires the previous plan). @@ -919,6 +1150,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { let failNextHistory = false /** Force-enders for currently open stream generators (timing hook: simulated connection loss). */ const streamBreakers = new Set<() => void>() + /** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */ + const retryScenarios = new Map() // Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which // is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let @@ -942,6 +1175,89 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq) append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } }) }, + /** Open one failed model step whose partial remains visible until llm/retry arrives. */ + beginModelRetry(id: string): void { + const sessionId = sid(id) + const turn = nextTurn.get(sessionId) ?? 0 + nextTurn.set(sessionId, turn + 1) + retryScenarios.set(sessionId, { turn, stepStarted: true }) + setRunning(sessionId, true) + append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } }) + append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } }) + append(sessionId, { type: 'step/start', data: { turn, step: 1 } }) + append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } }) + append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } }) + append(sessionId, { type: 'step/end', data: { turn, step: 1 } }) + }, + /** Record one retry decision, then open the next retry turn. */ + scheduleModelRetry(id: string, retry = 1, delayMs = 450): void { + const sessionId = sid(id) + const scenario = retryScenarios.get(sessionId) + if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) + if (!scenario.stepStarted) { + append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } }) + append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } }) + append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } }) + append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } }) + scenario.stepStarted = true + } + const failure = { code: 'TRANSPORT', message: '连接被重置' } + append(sessionId, { + type: 'llm/retry', + data: { + turn: scenario.turn, step: 1, + provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal', + retry, maxRetries: 2, delayMs, failure, + }, + }) + append(sessionId, { + type: 'turn/end', + data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } }, + }) + const next = nextTurn.get(sessionId) ?? scenario.turn + 1 + nextTurn.set(sessionId, next + 1) + append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } }) + scenario.turn = next + scenario.stepStarted = false + }, + /** Record one retry decision, then cancel its source turn before the retry starts. */ + cancelModelRetryDuringBackoff(id: string, delayMs = 450): void { + const sessionId = sid(id) + const scenario = retryScenarios.get(sessionId) + if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) + const failure = { code: 'TRANSPORT', message: '连接被重置' } + append(sessionId, { + type: 'llm/retry', + data: { + turn: scenario.turn, step: 1, + provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal', + retry: 1, maxRetries: 2, delayMs, failure, + }, + }) + append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } }) + retryScenarios.delete(sessionId) + setRunning(sessionId, false) + }, + /** Finish the timing-hook retry with a finalized response in the open retry turn. */ + completeModelRetry(id: string): void { + const sessionId = sid(id) + const scenario = retryScenarios.get(sessionId) + if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`) + retryScenarios.delete(sessionId) + append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } }) + append(sessionId, { + type: 'assistant/message', + surfaceOp: 'append', + data: { + turn: scenario.turn, + step: 1, + message: assistantMessage(text('重试后的完整回复')), + }, + }) + append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } }) + append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } }) + setRunning(sessionId, false) + }, /** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */ appendSilent(id: string, msg: string): void { const log = logOf(sid(id)) @@ -987,6 +1303,45 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return { sessions: { list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), + search: (request, signal) => { + if (signal.aborted) { + return err(request, { + code: 'cancelled', + message: 'fixture session search was aborted', + details: {}, + }) + } + const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value) + const matches = sessions.flatMap((summary) => { + const log = logs.get(summary.sessionId) ?? [] + const current = new Set(foldSurface(log).nodes) + const best = log.flatMap((event): FixtureSearchCandidate[] => { + if (!current.has(event.seq)) return [] + const eventText = searchEventText(event) + const document = searchTokenSpans(eventText) + const match = phraseMatch(document.tokens, query) + if (match.count === 0) return [] + return [{ + sessionId: summary.sessionId, + seq: event.seq, + time: event.time, + text: document.text, + matchCount: match.count, + matchStart: match.start, + matchEnd: match.end, + documentLength: Array.from(eventText).length, + }] + }).sort(compareSearchCandidates)[0] + return best === undefined ? [] : [best] + }).sort(compareSearchCandidates) + return ok(request, { + items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({ + sessionId: match.sessionId, + snippet: searchSnippet(match.text, match.matchStart, match.matchEnd), + })), + hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT, + }) + }, create: async (request) => { const workspace = request.payload.workspaceId === undefined ? undefined @@ -1691,20 +2046,30 @@ export class FixtureApiClient extends AbstractApiClient { protected override async callUnary( method: K, payload: RequestPayload, + signal?: AbortSignal, ): Promise>> { const request = rpcRequest(payload) const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload } this.onEnvelope(full) - const response = await this.dispatch(method, request as RpcRequest) as RpcResponse> + const response = await this.dispatch( + method, + request as RpcRequest, + signal ?? new AbortController().signal, + ) as RpcResponse> const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result } this.onEnvelope(fullResponse) return response } /** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */ - private dispatch(method: keyof RpcMethodMap, request: RpcRequest): Promise> { + private dispatch( + method: keyof RpcMethodMap, + request: RpcRequest, + signal: AbortSignal, + ): Promise> { switch (method) { case 'session.list': return this.api.sessions.list(request) + case 'session.search': return this.api.sessions.search(request, signal) case 'session.create': return this.api.sessions.create(request) case 'session.history': return this.api.sessions.history(request) case 'session.models': return this.api.sessions.models(request) @@ -1725,8 +2090,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'command.list': return this.api.commands.list(request) - // The in-memory execute never blocks, so a never-aborting signal is faithful here. - case 'command.execute': return this.api.commands.execute(request, new AbortController().signal) + case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 93f5153936..e286157e46 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -11,7 +11,7 @@ import { WebApiClient } from './web-api-client.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { - ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, + ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, @@ -25,7 +25,11 @@ export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi, } from './api.ts' -export { RpcId, AbstractApiClient, transportError } from './api.ts' +export { + RpcId, + AbstractApiClient, + transportError, +} from './api.ts' // Connection loop types are public through ConnectionHandle.start; the // controller remains package-internal. diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 5524c3a20b..0d58800279 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -4,7 +4,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame, - RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry, + RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -44,6 +44,8 @@ export class FakeApiClient implements IApiClient { // Programmable slots (defaults answer OK-empty); reassign per case. onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onSearch: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ items: [], hasMore: false })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) @@ -87,12 +89,17 @@ export class FakeApiClient implements IApiClient { private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] + lastSearchSignal: AbortSignal | undefined // Parameter annotations below are local structural types on purpose: the CI // lint lane runs without built artifacts, where IApiClient's wire types // (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument. readonly sessions: IApiClient['sessions'] = { list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)), + search: (payload: unknown, signal?: AbortSignal) => { + this.lastSearchSignal = signal + return this.record('session.search', payload, this.onSearch(payload)) + }, create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)), history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 6dc8d7cb42..f70c4eb421 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -19,6 +19,10 @@ interface TimingHooks { failNextHistory(): void appendUser(id: string, msg: string): void appendTitle(id: string, title: string): void + beginModelRetry(id: string): void + scheduleModelRetry(id: string, retry?: number, delayMs?: number): void + cancelModelRetryDuringBackoff(id: string, delayMs?: number): void + completeModelRetry(id: string): void appendSilent(id: string, msg: string): void breakStreams(): void } @@ -48,6 +52,59 @@ describe('createFixtureApi', () => { expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material }) + it('searches current message text with literal unicode61-style token phrases', async () => { + const api = createFixtureApi() + const signal = new AbortController().signal + const phrase = await api.sessions.search(req({ query: 'FIXTURE 历史消息' }), signal) + expect(phrase.result).toMatchObject({ + ok: true, + value: { + items: [{ sessionId: 'fx-alpha' }], + hasMore: false, + }, + }) + if (!phrase.result.ok) throw new Error('search failed') + expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息') + + timing().appendUser( + 'fx-alpha', + `${'leading context '.repeat(20)}late café token${' trailing context'.repeat(20)}`, + ) + const late = await api.sessions.search(req({ query: 'LATE CAFE TOKEN' }), signal) + if (!late.result.ok) throw new Error('late search failed') + const lateSnippet = late.result.value.items[0]?.snippet ?? '' + expect(lateSnippet).toContain('late café token') + expect(lateSnippet.startsWith('…')).toBe(true) + expect(lateSnippet.endsWith('…')).toBe(true) + expect(Array.from(lateSnippet).length).toBeLessThanOrEqual(120) + + timing().appendUser('fx-alpha', 'Greek final sigma: ος') + const finalSigma = await api.sessions.search(req({ query: 'ΟΣ' }), signal) + if (!finalSigma.result.ok) throw new Error('final sigma search failed') + expect(finalSigma.result.value.items[0]?.snippet).toContain('ος') + + const substring = await api.sessions.search(req({ query: 'ixtur' }), signal) + expect(substring.result).toEqual({ + ok: true, + value: { items: [], hasMore: false }, + }) + const punctuationOnly = await api.sessions.search(req({ query: '*' }), signal) + expect(punctuationOnly.result).toEqual({ + ok: true, + value: { items: [], hasMore: false }, + }) + const reasoningOnly = await api.sessions.search(req({ query: '思考过程' }), signal) + expect(reasoningOnly.result).toEqual({ + ok: true, + value: { items: [], hasMore: false }, + }) + + const aborted = new AbortController() + aborted.abort() + await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal)) + .resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } }) + }) + it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => { const api = createFixtureApi() const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 })) @@ -761,8 +818,18 @@ describe('createFixtureApi', () => { hooks.appendSilent('fx-alpha', '静默丢帧') hooks.appendUser('fx-alpha', '正常直播') hooks.appendTitle('fx-alpha', 'Fixture 修订标题') + hooks.beginModelRetry('fx-alpha') + hooks.scheduleModelRetry('fx-alpha') + hooks.completeModelRetry('fx-alpha') + hooks.beginModelRetry('fx-alpha') + hooks.cancelModelRetryDuringBackoff('fx-alpha') await vi.waitFor(() => { expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true) + expect(seen.some(f => f.type === 'session/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true) + expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true) + expect(seen.some(f => f.type === 'session/event' + && f.event.type === 'turn/end' + && f.event.data.reason.kind === 'aborted')).toBe(true) expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true) }) expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false) @@ -819,6 +886,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { it('covers the whole unary dispatch table', async () => { const client = new FixtureApiClient() + expect((await client.sessions.search( + { query: 'fixture' }, + new AbortController().signal, + )).result.ok).toBe(true) const created = await client.sessions.create({}) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 80bf46a996..c3dfc36e65 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/modules/README.md -README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da -README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72 +README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8 +README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index efba9e2eb0..99565b349d 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -8,6 +8,8 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook). +The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures. + ## Model Experience None, as the module loader is browser-side kernel machinery; nothing here reaches a model request. diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index b057bfdd8c..a8ed0a4949 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -8,6 +8,8 @@ 解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。 +Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。 + ## 模型体验 无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。 diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index ecfc31b77f..694295e7f2 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -58,6 +58,47 @@ interface PkgMeta { immediately: boolean } +/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */ +const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch' + +/** Missing built client export, retained as structured data for activation-error grouping. */ +class MissingClientBundleError extends Error { + constructor( + readonly packageName: string, + readonly clientPath: string, + cause: unknown, + ) { + super( + [ + `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`, + ` package: ${packageName}`, + ` path: ${clientPath}`, + ].join('\n'), + { cause }, + ) + } +} + +/** Activation failures grouped by actionable package-build errors and unrelated failures. */ +class ClientPackageCompositionError extends AggregateError { + constructor(failures: Error[]) { + const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError) + const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError)) + const packageNoun = failures.length === 1 ? 'package' : 'packages' + const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`] + if (missingBundles.length > 0) { + lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`) + for (const error of missingBundles) { + lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`) + } + } + if (otherFailures.length > 0) { + lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`)) + } + super(failures, lines.join('\n')) + } +} + /** One composed table row: the wire entry plus its bundle path. */ interface WebPluginRecord { entry: WebBootEntry @@ -138,7 +179,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string { * + bundle route + index tap. Construction runs the activation scan * synchronously — a malformed declaration or missing bundle among the * already-loaded entries aggregates into one loud throw (FAILED fiber; the - * boot sweep reports it). + * boot activation audit reports it). */ export class ClientModuleHostService extends Service { static inject = ['httpServer', 'loader'] @@ -194,10 +235,7 @@ export class ClientModuleHostService extends Service { const failures: Error[] = [] this.flush(err => failures.push(err)) if (failures.length > 0) { - throw new AggregateError( - failures, - `client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`, - ) + throw new ClientPackageCompositionError(failures) } ctx.effect( @@ -322,6 +360,22 @@ export class ClientModuleHostService extends Service { return meta } + /** + * Read the activation-time bundle revision. + * @param pkgName - package that declares the client bundle. + * @param clientPath - absolute path of the built client artifact. + * @returns the bundle content's short hash for use as its revision. + * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged. + */ + private initialBundleRevision(pkgName: string, clientPath: string): string { + try { + return shortHash(readFileSync(clientPath)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + throw new MissingClientBundleError(pkgName, clientPath, error) + } + } + /** Reconcile one entry name against the live loader entries. @returns whether the table changed. */ private processOne(entryName: string): boolean { let qualifies = false @@ -337,7 +391,7 @@ export class ClientModuleHostService extends Service { if (meta === null) return false // The rev rides the row from here on: a fiber restart reuses the row (and // its rev) untouched; only rebuilt() re-reads the bundle. - const rev = shortHash(readFileSync(meta.clientPath)) + const rev = this.initialBundleRevision(entryName, meta.clientPath) this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath }) return true } diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts new file mode 100644 index 0000000000..3eb99c0ead --- /dev/null +++ b/packages/client/modules/tests/node-half.spec.ts @@ -0,0 +1,87 @@ +/** Node-half composition diagnostics for package metadata and built client bundles. */ + +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import { afterEach, describe, expect, it } from 'vitest' +import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver' +import { ClientModuleHostService } from '../src/index.ts' + +let root: string | undefined + +afterEach(() => { + if (root !== undefined) rmSync(root, { recursive: true, force: true }) + root = undefined +}) + +/** Create a resolvable dshClient package whose client export points at the returned path. */ +function writePackage(packageName: string): string { + root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-'))) + const pkgRoot = join(root, 'node_modules', ...packageName.split('/')) + const clientPath = join(pkgRoot, 'lib', 'client.js') + mkdirSync(pkgRoot, { recursive: true }) + writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ + name: packageName, + exports: { + './client': './lib/client.js', + './package.json': './package.json', + }, + dshClient: { platform: 'web' }, + })) + return clientPath +} + +/** Construct the node-half service over the enabled fixture entries. */ +function construct(packageNames: string[]): ClientModuleHostService { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(root!).href + '/' + ctx.provide('loader', { + *entries() { + for (const packageName of packageNames) { + yield { options: { name: packageName }, fiber: {}, disabled: false } + } + }, + }) + const httpServer: Pick = { + port: 0, + register: () => () => {}, + tapIndex: () => () => {}, + } + ctx.provide('httpServer', httpServer as HttpServerService) + return new ClientModuleHostService(ctx) +} + +describe('client bundle activation', () => { + it('groups missing bundles under one source-build instruction with a package/path list', () => { + const firstName = '@fixture/missing-first' + const secondName = '@fixture/missing-second' + const firstPath = writePackage(firstName) + const secondPath = writePackage(secondName) + expect(() => construct([firstName, secondName])).toThrow([ + 'client-modules: 2 client packages failed to compose:', + ' client bundles not found; run `pnpm run build` before launch:', + ` - package: ${firstName}`, + ` path: ${firstPath}`, + ` - package: ${secondName}`, + ` path: ${secondPath}`, + ].join('\n')) + }) + + it('does not report other bundle read failures as missing builds', () => { + const packageName = '@fixture/unreadable-client' + const clientPath = writePackage(packageName) + mkdirSync(clientPath, { recursive: true }) + let thrown: unknown + try { + construct([packageName]) + } catch (error) { + thrown = error + } + expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:') + expect(String(thrown)).toContain(' other failures:') + expect(String(thrown)).toContain('EISDIR') + expect(String(thrown)).not.toContain('pnpm run build') + }) +}) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 1a4a8c491c..d49eeb7cdb 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 5a548f43a4c5e606a4ce3b8f30bd7ddf544ea53a -README.zh.md: 4c9342c31f460e32e07b19ed690c1bf8b74b3f88 +README.md: 8c00049a02c29037b0516431dd2982e6322e153b +README.zh.md: 4eb58c23395d49caa55ad995238100af11fc6522 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 5a548f43a4..8c00049a02 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -12,6 +12,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. +`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. + ## New Session and the blank mirror `WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. @@ -38,6 +40,10 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr `SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op. +## Model retry projection + +The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node. + ## Session forking `ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 4c9342c31f..4eb58c2339 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -12,6 +12,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 +`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 + ## New Session 与 blank 镜像 `WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 @@ -20,9 +22,9 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。 -## 人类对话记录 +## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是人类对话记录,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩缝隙插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩缝隙自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 @@ -32,12 +34,16 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## Code Mode 子调用索引 -`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入对话记录 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。 +`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript 的 `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。 ## Session 标题投影 `SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。 +## 模型重试投影 + +Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose(资源释放)时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。 + ## 会话 fork `ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。 @@ -52,10 +58,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 #### KV Cache 影响 -更改目标可能改变提供方侧的缓存复用,或使其失效;该包(package)本身不会改变提示词前缀。 +更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。 ## 已知限制与暂缓事项 -- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber dispose(资源释放) → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。 +- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber dispose → 注册级联 → 样式移除)随 HMR(热模块替换)项目落地。 - **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的会话精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ 会话位于 stage);在 staged 状态下被移除的会话,其 scope 会冻结保留,直到 stage 转向其他会话,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 - **插件组合包从该包导入值时必须使用 `/client` 子路径**:裸包名不在 loader externals 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配。这是空状态 P0 的事故复盘(postmortem)所记录的问题。 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index a6f96b9d47..f9cc6a308c 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -37,6 +37,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", @@ -50,6 +51,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 280bc602eb..79f20a234c 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -8,8 +8,9 @@ * explicit act of widening what features may do to the sessions domain. */ import type { Context } from 'cordis' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionSearchResultItem } from '../sessions/manager.ts' import type { SessionBinding, SessionListState, SessionProvideDescriptor, } from '../sessions/service.ts' @@ -22,6 +23,12 @@ export interface ISessions { readonly list: ObservableSnapshot /** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */ readonly currentProvideInfo: HostObservable + /** + * The `session.search` result bound the wire schema fixes, exposed to + * presentation as injected data. Not per-connection state: every transport + * (fixture included) reports the same number. + */ + readonly searchResultLimit: number /** * Select a session as current. * @param id - session id (must exist in the list; unknown ids fail loud). @@ -29,6 +36,17 @@ export interface ISessions { open(id: SessionId): void /** Clear the current selection into the no-session view state. */ clear(): void + /** + * Search the Host's visible message-content index. Results stay + * request-local; the list snapshot remains the metadata authority. + * @param query - non-blank literal phrase. + * @param signal - cancellation for a superseded search. + * @returns bounded results, or a business/transport error. + */ + search( + query: string, + signal: AbortSignal, + ): Promise> /** * Fork a session from a completed-turn prefix of the source; on resolution * the child is in the list store and `open()` can target it. diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 5754b3d68a..ef923c9226 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -31,7 +31,7 @@ export type { IWorkspaces } from './contract/workspaces.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, } from './sessions/service.ts' -export type { SessionListPhase } from './sessions/manager.ts' +export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts' export type { WorkspaceListPhase } from './workspaces/manager.ts' export type { WorkspaceListState } from './workspaces/service.ts' export type { @@ -45,7 +45,8 @@ export type { export type { AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase, - ContextMessageNode, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, + ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, + RunningToolCall, SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index adc638161a..615ac27e77 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -5,6 +5,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView, @@ -121,6 +122,19 @@ export interface ContextMessageNode { source: unknown } +/** Durable notice that a closed failed step is waiting for a model-request retry. */ +export type ModelRetryNode = LlmRetryEventData & { + kind: 'model-retry' + seq: number + /** Unix epoch ms from the llm/retry session event. */ + time: number + /** + * Client-derived lifecycle: scheduled until a retry turn starts, started + * once it does, or cancelled when the failed turn aborts first. + */ + retryState: 'scheduled' | 'started' | 'cancelled' +} + /** A tool result paired (when in-window) with its call head. */ export interface ToolResultNode { kind: 'tool-result' @@ -208,6 +222,7 @@ export type ConversationNode = | AssistantMessageNode | SteeringMessageNode | ContextMessageNode + | ModelRetryNode | ToolResultNode | CommandNode | CompactionSummaryNode @@ -291,7 +306,7 @@ export interface PromptError { /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId - /** Human transcript (finalized conversation nodes in log order). */ + /** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */ nodes: readonly ConversationNode[] partial: PartialAssistant | null runningCalls: readonly RunningToolCall[] diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 4ba134b321..a89d8dbc31 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -2,7 +2,10 @@ // dispatch entry + list state, constructed and held by SessionsService (one per client runtime). // List data never enters zustand; React connects via subscribe/getListSnapshot. -import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' +import type { + IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, + SessionSummary, WorkspaceId, +} from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -27,6 +30,12 @@ import { Session } from './session.ts' */ export type SessionListPhase = 'pending' | 'ready' +/** Request-local content hit returned to sidebar search consumers. */ +export interface SessionSearchResultItem { + sessionId: SessionId + snippet: string +} + /** Immutable session-list snapshot for useSessionList. */ export interface SessionListSnapshot { items: readonly SessionListEntry[] @@ -248,6 +257,24 @@ export class SessionManager { return this.listInflight } + /** + * Search visible session message content without adding transient query + * state to the list snapshot. + * @param query - non-blank literal phrase. + * @param signal - cancellation for superseded UI queries. + * @returns the Host result or a folded transport error. + */ + async search( + query: string, + signal: AbortSignal, + ): Promise> { + try { + return (await this.api.sessions.search({ query }, signal)).result + } catch (error: unknown) { + return transportError(error) + } + } + /** * Contract session.create; on success merge into summaries immediately (no * wait for the next refresh). A created session is blank by definition diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 342fc3b62e..93ecb3c791 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -16,7 +16,12 @@ * survives frozen (read-only view) until the stage moves on. */ import type { Context, Fiber } from 'cordis' -import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' +import type { + IApiClient, RpcError, RpcResult, SessionId, WorkspaceId, +} from '@deepseek-ai/dsh-client-connection/client' +// Value import from the inline-safe wire layer (not the connection plugin): +// plugin-to-plugin value imports are a bundle purity error. +import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, } from '@deepseek-ai/dsh-client-ui-slots' @@ -26,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts' import type { ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' import { SessionManager } from './manager.ts' -import type { SessionListPhase } from './manager.ts' +import type { SessionListPhase, SessionSearchResultItem } from './manager.ts' import { SessionProvideChannel } from './provide.ts' import type { Session } from './session.ts' @@ -189,6 +194,13 @@ export interface SessionProvideDescriptor { /** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */ export class SessionsService implements ISessions { + /** + * The wire schema's own result bound, re-exposed for presentation plugins as + * injected data. Not per-connection state: the `session.search` response + * schema caps `items` at this constant, so every transport (fixture included) + * reports the same number. + */ + readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */ readonly list: SnapshotStore /** The object-layer instance cluster and frame dispatch entry. */ @@ -228,7 +240,10 @@ export class SessionsService implements ISessions { * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. */ - constructor(private readonly rootCtx: Context, api: IApiClient) { + constructor( + private readonly rootCtx: Context, + api: IApiClient, + ) { this.selection = createSnapshotStore<{ sessionId?: SessionId }>( {}, { persist: { name: 'dsh.sessions.current' } }) @@ -307,6 +322,20 @@ export class SessionsService implements ISessions { return this.manager.refreshList() } + /** + * Search the Host's visible message-content index. Results stay + * request-local; the list snapshot remains the metadata authority. + * @param query - non-blank literal phrase. + * @param signal - cancellation for a superseded search. + * @returns bounded results or a business/transport error. + */ + search( + query: string, + signal: AbortSignal, + ): Promise> { + return this.manager.search(query, signal) + } + /** * Route a mux stream envelope into the Session object layer. * @param envelope - validated mux stream envelope. diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index aea5bc9de2..1e2bc04d9a 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -2,6 +2,7 @@ import type { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError, @@ -12,8 +13,8 @@ import type { import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import type { SessionFace } from '../contract/session.ts' import type { - CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, - PromptError, QueuedMessage, RunningToolCall, + CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode, + OpenState, PromptError, QueuedMessage, RunningToolCall, } from './conversation.ts' import type { PendingInteraction } from './pending.ts' import { PendingWait } from './pending.ts' @@ -26,6 +27,10 @@ import type { ProjectionsBaseline } from './projection-store.ts' /** Messages requested per history page. */ export const PAGE_MESSAGES = 50 +// Browser bundles cannot value-import the host timeout library. This protocol +// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests. +const MAX_RETRY_DELAY_MS = 2_147_483_647 + /** Manager-owned observers of a Session object's local state edges. */ export interface SessionOptions { /** @@ -88,11 +93,10 @@ export class Session implements SessionFace { private readonly transcript = new TranscriptAdapter() private partial: PartialAccumulator | null = null private openCalls = new Map() - /** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq. - * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. - * Owned here rather than by the adapter: these nodes come from the turn/end sweep this class already - * runs over the window, and the transcript array is seq-monotonic, so a plain seq merge is correct. */ - private frozenNodes: ConversationNode[] = [] + /** Operational notices and interrupted-turn terminal nodes merged into the flow by seq. + * Derived from window events and rebuilt with partial/openCalls; the transcript is + * seq-monotonic, so a plain seq merge preserves event order. */ + private derivedNodes: ConversationNode[] = [] private pending = new Map() // Revision counters preserve array identity when derived content is unchanged, so // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every @@ -102,12 +106,12 @@ export class Session implements SessionFace { private callsCache: { rev: number; value: RunningToolCall[] } | null = null private pendingRev = 0 private pendingCache: { rev: number; value: PendingInteraction[] } | null = null + private derivedRev = 0 + private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null /** Authoritative stream-only inbox snapshot; pending work never hits history. */ private queued: QueuedMessage[] = [] private queueRev = 0 private queueCache: { rev: number; value: QueuedMessage[] } | null = null - private frozenRev = 0 - private nodesCache: { projected: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null /** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends * copy-on-write the per-parent array so published snapshot references never mutate. */ private codeDispatches = new Map() @@ -628,8 +632,28 @@ export class Session implements SessionFace { } /** Per-event side effects (right column of the §A.9 dispatch table): - * chunk accumulation / partial clear on finalize / openCalls add-remove. */ + * chunk/retry projection and openCalls add-remove. */ private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void { + const eventType = event.type as string + if (eventType === 'llm/retry') { + const data = parseRetryEventData(event.data) + if (data === null) { + console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`) + return + } + if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) { + this.partial = null + } + this.derivedNodes.push({ + kind: 'model-retry', + seq: event.seq, + time: event.time, + retryState: 'scheduled', + ...data, + }) + this.derivedRev++ + return + } // The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by // the host-side dsh-tools plugin whose types cannot enter the client // program (its host Context merges collide with the client's), so this @@ -690,6 +714,10 @@ export class Session implements SessionFace { return } switch (event.type) { + case 'turn/start': { + if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started') + return + } case 'assistant/chunk': { const { turn, step, chunk } = event.data if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) { @@ -718,6 +746,9 @@ export class Session implements SessionFace { return } case 'turn/end': { + if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') { + this.settleScheduledRetry('cancelled', event.data.turn) + } // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. // Shared by live and window-replay paths, so a refresh reconstructs the same frozen node @@ -727,12 +758,12 @@ export class Session implements SessionFace { const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true)) if (visible) { // Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn. - this.frozenNodes.push({ + this.derivedNodes.push({ kind: 'assistant', seq: event.seq - 0.9, time: event.time, turn: this.partial.turn, step: this.partial.step, blocks, interrupted: true, }) - this.frozenRev++ + this.derivedRev++ } this.partial = null } @@ -742,7 +773,7 @@ export class Session implements SessionFace { this.openCalls.delete(callId) this.callsRev++ // The spinner card becomes an interrupted terminal card (never vanishes mid-flow). - this.frozenNodes.push({ + this.derivedNodes.push({ kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time, callId, call: { name: call.name, argsRaw: call.argsRaw }, @@ -750,7 +781,7 @@ export class Session implements SessionFace { content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, callView: call.callView, resultView: null, }) - this.frozenRev++ + this.derivedRev++ } return } @@ -759,15 +790,36 @@ export class Session implements SessionFace { } } - /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps - * paging/stitching consistent, and makes the live freeze and the history replay converge on the - * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */ + /** + * Settle the newest scheduled retry, optionally restricted to its failed turn. + * @param retryState - next client projection state to publish. + * @param turn - failed turn required for cancellation; omitted for the next retry turn start. + */ + private settleScheduledRetry( + retryState: Exclude, + turn?: number, + ): void { + const index = this.derivedNodes.findLastIndex(node => + node.kind === 'model-retry' + && node.retryState === 'scheduled' + && (turn === undefined || node.turn === turn)) + if (index < 0) return + const node = this.derivedNodes[index] + /* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */ + if (node?.kind !== 'model-retry') return + this.derivedNodes[index] = { ...node, retryState } + this.derivedRev++ + } + + /** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps + * paging/stitching consistent, and makes live handling and history replay converge on the same + * retry notices and interrupted nodes. */ private rebuildDerivedFromWindow(): void { this.partial = null this.openCalls.clear() this.callsRev++ - this.frozenNodes = [] - this.frozenRev++ + this.derivedNodes = [] + this.derivedRev++ this.codeDispatches = new Map() this.dispatchesRev++ for (let i = 0; i < this.events.length; i++) { @@ -784,18 +836,17 @@ export class Session implements SessionFace { private buildSnapshot(): ConversationSnapshot { const projected = this.transcript.nodes() - // Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order. - // The transcript is seq-monotonic (log order), so sorting the union by seq is exactly the - // flow order. The merged array is cached on (projected reference, frozenRev) so an unchanged - // flow keeps its reference across snapshot swaps (§A.9.4). + // Derived interruption nodes ride fractional seqs while retry notices keep their event seq. + // The transcript is seq-monotonic, so sorting the union preserves flow order. Cache the + // merge on (projected reference, derivedRev) to retain identity across unrelated swaps. let nodes: readonly ConversationNode[] - if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.frozenRev === this.frozenRev) { + if (this.nodesCache !== null && this.nodesCache.projected === projected && this.nodesCache.derivedRev === this.derivedRev) { nodes = this.nodesCache.value } else { - nodes = this.frozenNodes.length === 0 + nodes = this.derivedNodes.length === 0 ? projected - : [...projected, ...this.frozenNodes].sort((a, b) => a.seq - b.seq) - this.nodesCache = { projected, frozenRev: this.frozenRev, value: nodes } + : [...projected, ...this.derivedNodes].sort((a, b) => a.seq - b.seq) + this.nodesCache = { projected, derivedRev: this.derivedRev, value: nodes } } if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } @@ -838,6 +889,58 @@ export class Session implements SessionFace { } } +/** Validate the plugin-owned payload at the session-event wire boundary. */ +function parseRetryEventData(value: unknown): LlmRetryEventData | null { + if (value === null || typeof value !== 'object') return null + const data = value as Record + const failure = data.failure + if (failure === null || typeof failure !== 'object') return null + const failureData = failure as Record + if (!nonNegativeSafeInteger(data.turn) + || !nonNegativeSafeInteger(data.step) + || typeof data.provider !== 'string' + || data.provider.length === 0 + || typeof data.policyKey !== 'string' + || data.policyKey.length === 0 + || !positiveSafeInteger(data.retry) + || typeof data.delayMs !== 'number' + || !Number.isFinite(data.delayMs) + || data.delayMs < 0 + || data.delayMs > MAX_RETRY_DELAY_MS + || typeof failureData.message !== 'string' + || failureData.message.length === 0 + || typeof failureData.code !== 'string' + || failureData.code.length === 0) return null + if (data.mode === 'normal') { + if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null + } else if (data.mode === 'always') { + if ('maxRetries' in data) return null + } else { + return null + } + if (failureData.status !== undefined + && (typeof failureData.status !== 'number' + || !Number.isInteger(failureData.status) + || failureData.status < 100 + || failureData.status > 599)) return null + if (failureData.providerRetryAfterMs !== undefined + && (typeof failureData.providerRetryAfterMs !== 'number' + || !Number.isFinite(failureData.providerRetryAfterMs) + || failureData.providerRetryAfterMs <= 0)) return null + if (failureData.requestId !== undefined + && (typeof failureData.requestId !== 'string' + || failureData.requestId.length === 0)) return null + return data as unknown as LlmRetryEventData +} + +function nonNegativeSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +function positiveSafeInteger(value: unknown): value is number { + return nonNegativeSafeInteger(value) && value > 0 +} + /** * The composerPhase judgment — the single site that knows the predicate * (consumers switch on the result, never re-derive). Monotone per session diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index d5b29f10a9..d389efe319 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -7,6 +7,7 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' +import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import * as RuntimeClient from '../src/client/index.ts' import type { SessionsService } from '../src/client/sessions/service.ts' import type { WorkspacesService } from '../src/client/workspaces/service.ts' @@ -50,6 +51,8 @@ describe('runtime client apply', () => { const workspaces = bench.ctx.get('workspaces') expect(sessions !== undefined).toBe(true) expect(workspaces !== undefined).toBe(true) + // The bound the wire schema enforces, not a per-connection negotiation. + expect((sessions as SessionsService).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT) if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply') expect(bench.sinks).toBeDefined() diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index f92ee787f4..3e9f82b883 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -63,7 +63,25 @@ export const ev = { }), stepEnd: (seq: number, turn: number, step = 0): SessionEvent => at(seq, { type: 'step/end', data: { turn, step } }), - turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => + retry: ( + seq: number, + turn: number, + step = 0, + retry = 1, + maxRetries = 2, + delayMs = 500, + message = 'temporary transport failure', + ): SessionEvent => + at(seq, { + type: 'llm/retry', + data: { + turn, step, + provider: 'fake', mode: 'normal', policyKey: 'fake-normal', + retry, maxRetries, delayMs, + failure: { code: 'TRANSPORT', message }, + }, + }), + turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent => at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent => at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }), diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 041ea02dee..06d948ae83 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -4,7 +4,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame, - RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry, + RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -61,6 +61,8 @@ export class FakeApiClient implements IApiClient { // Programmable slots (defaults answer OK-empty); reassign per case. onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onSearch: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ items: [], hasMore: false })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' } onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) @@ -106,12 +108,17 @@ export class FakeApiClient implements IApiClient { private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] + lastSearchSignal: AbortSignal | undefined // Parameters carry local structural annotations: the CI lint lane runs // without built lib/, so IApiClient's indexed-access types collapse to any // and inferred parameters would trip no-unsafe-argument. readonly sessions: IApiClient['sessions'] = { list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)), + search: (payload: unknown, signal?: AbortSignal) => { + this.lastSearchSignal = signal + return this.record('session.search', payload, this.onSearch(payload)) + }, create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)), history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 6a0f30f4c6..1330a49768 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -206,6 +206,49 @@ describe('list lifecycle', () => { }) }) +describe('search', () => { + it('returns bounded Host results and forwards the caller signal', async () => { + const api = new FakeApiClient() + api.onSearch = () => Promise.resolve(ok({ + items: [{ sessionId: S1, snippet: 'matching excerpt' }], + hasMore: true, + })) + const manager = new SessionManager(api) + const signal = new AbortController().signal + + await expect(manager.search('exact phrase', signal)).resolves.toEqual({ + ok: true, + value: { + items: [{ sessionId: S1, snippet: 'matching excerpt' }], + hasMore: true, + }, + }) + expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }]) + expect(api.lastSearchSignal).toBe(signal) + }) + + it('preserves business errors and folds transport failures', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + api.onSearch = () => Promise.resolve(err({ + code: 'internal', + message: 'index unavailable', + details: {}, + })) + const signal = new AbortController().signal + await expect(manager.search('first', signal)).resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: 'index unavailable' }, + }) + + api.onSearch = () => Promise.reject(new Error('wire down')) + await expect(manager.search('second', signal)).resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: 'wire down' }, + }) + }) +}) + describe('host frame routing', () => { it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => { const api = new FakeApiClient() diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 3e748621a9..fdb961d6f2 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' @@ -161,6 +162,214 @@ describe('live event path', () => { expect((last as { interrupted?: true }).interrupted).toBeUndefined() }) + it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => { + const { session } = await opened() + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + const retryTurn = [ + ev.turnStart(6, 1), + ev.user(7, '请重试'), + ev.stepStart(8, 1), + ev.chunkStart(9, 1), + ev.chunkText(10, 1, '不完整回复'), + ev.stepEnd(11, 1), + ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'), + at(13, { + type: 'turn/end', + data: { + turn: 1, + reason: { + kind: 'error', step: 0, + failure: { code: 'TRANSPORT', message: '连接被重置' }, + }, + }, + }), + at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }), + ev.stepStart(15, 2), + ev.assistant(16, 2, '完整回复'), + ev.stepEnd(17, 2), + ev.turnEnd(18, 2), + ] + for (const event of retryTurn.slice(0, 7)) feed(event) + + let snapshot = session.getSnapshot() + expect(snapshot.partial).toBeNull() + expect(snapshot.nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'scheduled', + turn: 1, + step: 0, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: 1, + maxRetries: 2, + delayMs: 450, + failure: { code: 'TRANSPORT', message: '连接被重置' }, + }) + expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复') + + for (const event of retryTurn.slice(7)) feed(event) + snapshot = session.getSnapshot() + expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant']) + expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' }) + expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] }) + + const replay = makeSession() + replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn]) + await replay.session.open() + expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes) + expect(replay.session.getSnapshot().partial).toBeNull() + }) + + it('rejects retry payloads outside the producer contract without retracting the current partial', async () => { + const { session } = await opened() + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.turnStart(6, 1)) + feed(ev.chunkStart(7, 1)) + feed(ev.chunkText(8, 1, '仍在生成')) + const valid = { + turn: 1, step: 0, + provider: 'fake', mode: 'normal', policyKey: 'fake-normal', + retry: 1, maxRetries: 2, delayMs: 500, + failure: { code: 'TRANSPORT', message: 'temporary failure' }, + } + const invalid = [ + { ...valid, turn: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, step: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, provider: '' }, + { ...valid, policyKey: '' }, + { ...valid, retry: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 }, + { ...valid, delayMs: -1 }, + { ...valid, delayMs: Number.POSITIVE_INFINITY }, + { ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 }, + { ...valid, failure: { ...valid.failure, message: '' } }, + { ...valid, failure: { ...valid.failure, code: '' } }, + { ...valid, failure: { ...valid.failure, status: '429' } }, + { ...valid, failure: { ...valid.failure, status: 99 } }, + { ...valid, failure: { ...valid.failure, status: 429.5 } }, + { ...valid, failure: { ...valid.failure, status: 600 } }, + { ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } }, + { ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } }, + { ...valid, failure: { ...valid.failure, requestId: 1 } }, + { ...valid, failure: { ...valid.failure, requestId: '' } }, + ] + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + for (const [index, data] of invalid.entries()) { + feed(at(9 + index, { type: 'llm/retry', data })) + } + expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }]) + expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([]) + expect(errorSpy).toHaveBeenCalledTimes(invalid.length) + expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9') + } finally { + errorSpy.mockRestore() + } + }) + + it('accepts complete retry payloads at the producer field boundaries', async () => { + const { session } = await opened() + session.handleMuxEnvelope('r' as never, { + type: 'session/event', + sessionId: SID, + event: at(6, { + type: 'llm/retry', + data: { + turn: Number.MAX_SAFE_INTEGER, + step: Number.MAX_SAFE_INTEGER, + provider: 'fake', + mode: 'normal', + policyKey: 'fake-normal', + retry: Number.MAX_SAFE_INTEGER, + maxRetries: Number.MAX_SAFE_INTEGER, + delayMs: MAX_TIMER_DELAY_MS, + failure: { + code: 'RATE_LIMIT', + message: 'provider busy', + status: 599, + providerRetryAfterMs: Number.MIN_VALUE, + requestId: 'req-1', + }, + }, + }), + }) + expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'scheduled', + retry: Number.MAX_SAFE_INTEGER, + delayMs: MAX_TIMER_DELAY_MS, + failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' }, + }) + }) + + it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => { + const { session } = await opened() + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + feed(at(6, { + type: 'llm/retry', + data: { + turn: 1, step: 0, + provider: 'fake', mode: 'always', policyKey: 'fake-always', + retry: 3, delayMs: 500, + failure: { code: 'TRANSPORT', message: 'retry forever' }, + }, + })) + expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'scheduled', + mode: 'always', + retry: 3, + }) + + feed(at(7, { + type: 'llm/retry', + data: { + turn: 2, step: 0, + provider: 'fake', mode: 'always', policyKey: 'fake-always', + retry: 4, maxRetries: 4, delayMs: 500, + failure: { code: 'TRANSPORT', message: 'unexpected maximum' }, + }, + })) + feed(at(8, { + type: 'llm/retry', + data: { + turn: 2, step: 0, + provider: 'fake', mode: 'sometimes', policyKey: 'fake-unknown', + retry: 4, delayMs: 500, + failure: { code: 'TRANSPORT', message: 'unknown mode' }, + }, + })) + expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toHaveLength(1) + expect(errorSpy).toHaveBeenCalledTimes(2) + } finally { + errorSpy.mockRestore() + } + }) + + it.each(['aborted', 'disposed'] as const)( + 'marks a scheduled retry as cancelled when its failed turn ends %s', + async (reason) => { + const { session } = await opened() + const feed = (event: SessionEvent) => { + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) + } + feed(ev.turnStart(6, 1)) + feed(ev.retry(7, 1)) + expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'scheduled', + }) + feed(ev.turnEnd(8, 1, reason)) + expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ + kind: 'model-retry', + retryState: 'cancelled', + }) + }, + ) + it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => { const { session } = await opened() const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } @@ -168,7 +377,7 @@ describe('live event path', () => { feed(ev.user(7, '要被打断的')) feed(ev.chunkStart(8, 1)) feed(ev.chunkText(9, 1, '说到一半')) - feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives + feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives const snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() const frozen = snapshot.nodes.at(-1) @@ -187,7 +396,7 @@ describe('live event path', () => { expect(session.getSnapshot().runningCalls).toEqual([]) // Second call never resolves: turn/end freezes it as an error card. feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}')) - feed(ev.turnEnd(10, 1, 'cancelled')) + feed(ev.turnEnd(10, 1, 'aborted')) const snapshot = session.getSnapshot() expect(snapshot.runningCalls).toEqual([]) expect(snapshot.nodes.at(-1)).toMatchObject({ @@ -227,7 +436,7 @@ describe('live event path', () => { feed(ev.user(9, '压缩后的提问')) feed(ev.chunkStart(10, 1)) feed(ev.chunkText(11, 1, '说到一半')) - feed(ev.turnEnd(12, 1, 'cancelled')) + feed(ev.turnEnd(12, 1, 'aborted')) expect(session.getSnapshot().nodes.map(n => n.kind)).toEqual([ 'user', 'assistant', 'compaction', 'user', 'assistant', ]) @@ -592,7 +801,7 @@ describe('remaining branches', () => { const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.turnStart(6, 1)) feed(ev.chunkStart(7, 1)) // empty text block only, no delta - feed(ev.turnEnd(8, 1, 'cancelled')) + feed(ev.turnEnd(8, 1, 'aborted')) const snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([]) @@ -606,7 +815,7 @@ describe('remaining branches', () => { feed(ev.turnStart(6, 1)) feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}')) feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn - feed(ev.turnEnd(9, 1, 'cancelled')) + feed(ev.turnEnd(9, 1, 'aborted')) const snapshot = session.getSnapshot() expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call']) expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true }) @@ -700,7 +909,7 @@ describe('remaining branches', () => { const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } feed(ev.turnStart(6, 1)) feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } })) - feed(ev.turnEnd(8, 1, 'cancelled')) + feed(ev.turnEnd(8, 1, 'aborted')) const frozen = session.getSnapshot().nodes.at(-1) expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] }) }) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 8687f208f0..9fabb0d8de 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -69,6 +69,29 @@ describe('list store projection', () => { }) }) +describe('search', () => { + it('delegates transient content search without changing the list snapshot', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + const before = b.svc.list.getSnapshot() + b.api.onSearch = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }], + hasMore: false, + })) + const signal = new AbortController().signal + + await expect(b.svc.search('needle', signal)).resolves.toEqual({ + ok: true, + value: { + items: [{ sessionId: 's1', snippet: 'matching excerpt' }], + hasMore: false, + }, + }) + expect(b.api.lastSearchSignal).toBe(signal) + expect(b.svc.list.getSnapshot()).toBe(before) + }) +}) + describe('scope tree', () => { it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => { const b = bench() diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index d7968cce84..f1512c7059 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../llm/llm-retry" + }, { "path": "../../support/invariants" } diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index 10b80bdd68..e892d9cd52 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-web-react": "^0.0.1", + "@deepseek-ai/dsh-host-apiproxy": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0", @@ -37,6 +38,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index f40f9a14a5..b26c033bb7 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -4,8 +4,11 @@ import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-cl import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId, - SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore, + SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' +// The double reports the wire schema's own search bound, like the production +// service — a transport-varying limit would be a fiction no client can see. +import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import { conversationSnapshot } from './fixtures.ts' import type { SessionFixture, Stabilizer } from './fixtures.ts' @@ -151,8 +154,8 @@ export interface TestSessionBinding { * * Implements the same ISessions face features receive as `ctx.sessions`, so * a production face change breaks this double at compile time; the extra - * members (add/updateSnapshot/setCurrent/remove/behavior/calls and the - * legacy provideInfo/maybeProvideInfo lookups) are bench-only surface. + * members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and + * the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface. */ export class TestSessions implements ISessions { /** The useSessions standard feed (list rows + current selection). */ @@ -168,8 +171,14 @@ export class TestSessions implements ISessions { /** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */ private readonly channel: SessionProvideChannel - /** Calls observed on the service-level face (open/clear), newest last. */ - readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = [] + /** Calls observed on the service-level face (open/clear/search/fork), newest last. */ + readonly calls: { method: 'open' | 'clear' | 'search' | 'fork'; args: unknown[] }[] = [] + + /** The wire schema's `session.search` result bound (production parity). */ + readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT + + /** Replaceable search behavior (see {@link TestSessions.stubSearch}). */ + private searchStub: ((query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }) | undefined /** * @param stabilize - the owning runtime's act wrapper. @@ -392,6 +401,27 @@ export class TestSessions implements ISessions { this.list.update((draft) => { draft.current = undefined }) } + /** + * Replace the sidebar-search result page (the call is still recorded). + * @param impl - hits for a query, as the Host would rank them. + */ + stubSearch(impl: (query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }): void { + this.searchStub = impl + } + + /** + * Content search over the fixture corpus (recorded). The default answers an + * empty page: content ranking is Host behavior, so a scenario that asserts + * hits declares them through {@link TestSessions.stubSearch}. + * @param query - non-blank literal phrase. + * @param signal - cancellation for a superseded search (recorded and forwarded). + * @returns the stubbed or empty result page. + */ + search(query: string, signal: AbortSignal): ReturnType { + this.calls.push({ method: 'search', args: [query, signal] }) + return Promise.resolve({ ok: true, value: this.searchStub?.(query, signal) ?? { items: [], hasMore: false } }) + } + /** * Recorded fork stub: no child materializes (benches asserting the full * fork flow drive the production service; this face only proves the call). diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 62bab2845a..8909f88162 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -221,6 +221,28 @@ describe('sessions', () => { ]) await runtime.dispose() }) + + it('answers search with an empty page until a scenario declares hits, recording every call', async () => { + const runtime = await runtimeWithFrame() + await runtime.sessions.add({ id: 's1' }) + const signal = new AbortController().signal + expect(runtime.sessions.searchResultLimit).toBeGreaterThan(0) + await expect(runtime.sessions.search('marker', signal)) + .resolves.toEqual({ ok: true, value: { items: [], hasMore: false } }) + runtime.sessions.stubSearch(query => ({ + items: [{ sessionId: 's1' as SessionId, snippet: `hit: ${query}` }], + hasMore: true, + })) + await expect(runtime.sessions.search('marker', signal)).resolves.toEqual({ + ok: true, + value: { items: [{ sessionId: 's1', snippet: 'hit: marker' }], hasMore: true }, + }) + expect(runtime.sessions.calls).toEqual([ + { method: 'search', args: ['marker', signal] }, + { method: 'search', args: ['marker', signal] }, + ]) + await runtime.dispose() + }) }) describe('stores', () => { diff --git a/packages/client/test-runtime/tsconfig.json b/packages/client/test-runtime/tsconfig.json index 3e8a8561f8..6a758c66f9 100644 --- a/packages/client/test-runtime/tsconfig.json +++ b/packages/client/test-runtime/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../host/apiproxy" } ] } diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx index 7fe5edcb86..456cdd4200 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.tsx +++ b/packages/client/ui-command/src/client/PopupSelectView.tsx @@ -12,7 +12,7 @@ import { useEffect, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { filterOptions } from './popup.ts' import type { PopupSelectController } from './popup.ts' @@ -60,23 +60,24 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { // closes the shell before its own handlers run; that click's target then // takes focus naturally, so no focusComposer here. useEffect(() => { - if (!state.open) return + if (!state.open || state.confirming !== null) return const onPointerDown = (ev: PointerEvent): void => { if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return popup.dismiss() } document.addEventListener('pointerdown', onPointerDown, true) return () => { document.removeEventListener('pointerdown', onPointerDown, true) } - }, [state.open, popup]) + }, [state.open, state.confirming, popup]) // Focus the search input after it mounts (separate effect so the ref is populated). useEffect(() => { - if (state.open) searchRef.current?.focus() - }, [state.open]) + if (state.open && state.confirming === null) searchRef.current?.focus() + }, [state.open, state.confirming]) if (!state.open) return null const rows = filterOptions(state.options, state.search) + const confirmation = state.confirming?.confirmation const onKeyDown = (ev: React.KeyboardEvent): void => { // ArrowLeft/ArrowRight fall through on purpose: the search input keeps @@ -103,55 +104,73 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { } return ( -

- { popup.setSearch(ev.currentTarget.value) }} - /> - {state.error !== null && ( -
- {state.error} - {state.status === 'failed' && ( - + <> + {state.confirming === null && ( +
+ { popup.setSearch(ev.currentTarget.value) }} + /> + {state.error !== null && ( +
+ {state.error} + {state.status === 'failed' && ( + + )} +
+ )} + {state.status === 'pending' &&
{t('status.loading')}
} + {state.submitting &&
{t('status.applying')}
} + {state.status === 'ready' && rows.length === 0 &&
{t('status.empty')}
} + {state.status === 'ready' && ( +
+ {rows.map((option, index) => ( +
{ void popup.select(index) }} + onMouseEnter={() => { popup.highlight(index) }} + > + {option.label} + {option.detail !== undefined && {option.detail}} + {option.active === true && } +
+ ))} +
)}
)} - {state.status === 'pending' &&
{t('status.loading')}
} - {state.submitting &&
{t('status.applying')}
} - {state.status === 'ready' && rows.length === 0 &&
{t('status.empty')}
} - {state.status === 'ready' && ( -
- {rows.map((option, index) => ( -
{ void popup.select(index) }} - onMouseEnter={() => { popup.highlight(index) }} - > - {option.label} - {option.detail !== undefined && {option.detail}} - {option.active === true && } -
- ))} -
+ {confirmation !== undefined && ( + { popup.acknowledge(value) }} + onCancel={() => { popup.cancelConfirmation() }} + onConfirm={() => { void popup.confirm() }} + /> )} -
+ ) } diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts index 61ab4de2e2..8498b5def8 100644 --- a/packages/client/ui-command/src/client/contract.ts +++ b/packages/client/ui-command/src/client/contract.ts @@ -6,12 +6,23 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' +/** Copy for an option that must be acknowledged before onSelect can run. */ +export interface SelectConfirmation { + readonly title: string + readonly description: string + readonly acknowledgeLabel: string + readonly cancelLabel: string + readonly confirmLabel: string +} + /** One option row of a popupSelect shell. */ export interface SelectOption { readonly id: string readonly label: string readonly detail?: string readonly active?: boolean + /** Optional in-page risk gate owned by the shared popup shell. */ + readonly confirmation?: SelectConfirmation } /** diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index e010c95227..76f3d8c551 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -24,7 +24,7 @@ export { filterOptions, PopupSelectController } from './popup.ts' export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts' export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx' export type { - CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption, + CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectConfirmation, SelectOption, } from './contract.ts' export type { CommandKey } from './locales.ts' diff --git a/packages/client/ui-command/src/client/popup.ts b/packages/client/ui-command/src/client/popup.ts index c2d30f3213..5e20911820 100644 --- a/packages/client/ui-command/src/client/popup.ts +++ b/packages/client/ui-command/src/client/popup.ts @@ -67,12 +67,17 @@ export interface PopupState { readonly active: number /** A select() settlement is in flight: further select/search/highlight no-op until it settles. */ readonly submitting: boolean + /** Option waiting for explicit risk acknowledgement; null during normal selection. */ + readonly confirming: SelectOption | null + /** Caller-controlled checkbox state for the pending confirmation. */ + readonly acknowledged: boolean /** Surfaced settlement failure (options load or onSelect); null when none. */ readonly error: string | null } const CLOSED: PopupState = { - open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null, + open: false, command: null, status: 'pending', options: [], search: '', active: 0, + submitting: false, confirming: null, acknowledged: false, error: null, } /** @@ -166,7 +171,7 @@ export class PopupSelectController { */ setSearch(search: string): void { const s = this.state.getSnapshot() - if (!s.open || s.submitting || search === s.search) return + if (!s.open || s.submitting || s.confirming !== null || search === s.search) return this.state.set({ ...s, search, active: 0 }) } @@ -177,7 +182,7 @@ export class PopupSelectController { */ move(dir: 1 | -1): void { const s = this.state.getSnapshot() - if (!s.open || s.status !== 'ready' || s.submitting) return + if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return const rows = filterOptions(s.options, s.search) if (rows.length === 0) return const active = (s.active + dir + rows.length) % rows.length @@ -191,7 +196,7 @@ export class PopupSelectController { */ highlight(index: number): void { const s = this.state.getSnapshot() - if (!s.open || s.status !== 'ready' || s.submitting) return + if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return this.state.set({ ...s, active: index }) } @@ -209,10 +214,46 @@ export class PopupSelectController { async select(index: number): Promise { const binding = this.binding const s = this.state.getSnapshot() - if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return + if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return const option = filterOptions(s.options, s.search)[index] if (option === undefined) return - this.state.set({ ...s, submitting: true, error: null }) + if (option.confirmation !== undefined) { + this.state.set({ ...s, confirming: option, acknowledged: false, error: null }) + return + } + await this.settle(binding, option) + } + + /** + * Update the explicit checkbox for the currently pending risk gate. + * @param acknowledged - whether the user has acknowledged the displayed risk. + */ + acknowledge(acknowledged: boolean): void { + const s = this.state.getSnapshot() + if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return + this.state.set({ ...s, acknowledged }) + } + + /** Cancel only the risk gate and return to the still-open option picker. */ + cancelConfirmation(): void { + const s = this.state.getSnapshot() + if (!s.open || s.submitting || s.confirming === null) return + this.state.set({ ...s, confirming: null, acknowledged: false }) + } + + /** Settle the gated option only after the checkbox is acknowledged. */ + async confirm(): Promise { + const binding = this.binding + const s = this.state.getSnapshot() + if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return + await this.settle(binding, s.confirming) + } + + /** Run the business settlement for an already admitted option. */ + private async settle(binding: OpenBinding, option: SelectOption): Promise { + const s = this.state.getSnapshot() + if (this.binding !== binding || !s.open || s.submitting) return + this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null }) try { await binding.spec.onSelect(option, binding.context) } catch (error) { diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx index d8fadaae7a..0bbe01b4a9 100644 --- a/packages/client/ui-command/tests/popup-view.spec.tsx +++ b/packages/client/ui-command/tests/popup-view.spec.tsx @@ -38,6 +38,17 @@ const OPTIONS: SelectOption[] = [ { id: 'light', label: 'Light', active: true }, { id: 'sepia', label: 'Sepia', detail: 'warm' }, ] +const GATED: SelectOption = { + id: 'full', + label: 'Full access', + confirmation: { + title: 'Enable Full access?', + description: 'Sensitive operations.', + acknowledgeLabel: 'I understand the risks', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }, +} const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } @@ -149,6 +160,37 @@ describe('PopupSelectView', () => { expect(view.container.childElementCount).toBe(0) }) + it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => { + const onSelect = vi.fn() + const { popup, consume } = await mountOpen({ + options: () => Promise.resolve([GATED]), + onSelect, + }) + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + expect(screen.queryByLabelText('/theme 选项')).toBeNull() + expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy() + const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement + expect(enable.disabled).toBe(true) + expect(onSelect).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' })) + expect(enable.disabled).toBe(false) + await act(async () => { fireEvent.click(enable) }) + expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A') + expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('canceling a gated option returns to the picker with acknowledgement reset', async () => { + await mountOpen({ options: () => Promise.resolve([GATED]) }) + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByLabelText('/theme 选项')).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + expect(screen.getByRole('checkbox').checked).toBe(false) + }) + it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => { let release!: () => void const onSelect = vi.fn(() => new Promise((resolve) => { release = resolve })) diff --git a/packages/client/ui-command/tests/popup.spec.ts b/packages/client/ui-command/tests/popup.spec.ts index 87a1070a40..68e8084b80 100644 --- a/packages/client/ui-command/tests/popup.spec.ts +++ b/packages/client/ui-command/tests/popup.spec.ts @@ -19,6 +19,17 @@ const OPTIONS: SelectOption[] = [ { id: 'light', label: 'Light', active: true }, { id: 'sepia', label: 'Sepia', detail: 'warm' }, ] +const GATED: SelectOption = { + id: 'full', + label: 'Full access', + confirmation: { + title: 'Enable Full access?', + description: 'Sensitive operations.', + acknowledgeLabel: 'I understand', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }, +} const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } @@ -200,6 +211,38 @@ describe('search / move / highlight over the filtered list', () => { }) describe('select', () => { + it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => { + const onSelect = vi.fn() + const deps = makeDeps() + const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps) + await popup.select(0) + expect(popup.state.getSnapshot()).toMatchObject({ + open: true, confirming: GATED, acknowledged: false, submitting: false, + }) + expect(onSelect).not.toHaveBeenCalled() + await popup.confirm() + expect(onSelect).not.toHaveBeenCalled() + popup.acknowledge(true) + await popup.confirm() + expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A) + expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('cancels a confirmation back to the picker without selecting or consuming', async () => { + const onSelect = vi.fn() + const deps = makeDeps() + const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps) + await popup.select(0) + popup.acknowledge(true) + popup.cancelConfirmation() + expect(popup.state.getSnapshot()).toMatchObject({ + open: true, confirming: null, acknowledged: false, submitting: false, + }) + expect(onSelect).not.toHaveBeenCalled() + expect(deps.consume).not.toHaveBeenCalled() + }) + it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => { const seen: Array<{ option: SelectOption; context: Ctx }> = [] const deps = makeDeps() diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 767fed1e71..ffd4e9e584 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: c40d59a8f44c352ea00654afe6a93131191cd28b -README.zh.md: 398d8d5d27bc0dd7e995d829343ae955e9d85035 +README.md: a5bd86be537a1048fc78814ccf27b07f9629f3ad +README.zh.md: c723b43857d0f0b0a20782dd8f9c550007a15e1e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index c40d59a8f4..a5bd86be53 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,13 +10,19 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission ` command line through the bar's injected `command` callback. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. -A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). +A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). + +A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)). + +A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)). + +The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). @@ -26,7 +32,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. +The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 398d8d5d27..c723b43857 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -4,21 +4,27 @@ 会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -压缩在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的对话记录。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 +压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 -视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 +视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包(package)自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开后的 141px 滚动区会以内联 JSON 的形式有界展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 -声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 +声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 -工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 +声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 +声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。 + +聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。 + +工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 + +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 @@ -26,7 +32,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 @@ -48,4 +54,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 - **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。 -- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。 +- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript 中,因此从外部提交的 steering 在回放时仍能如实呈现。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index da8c9b1910..0be0f4f534 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -20,6 +20,8 @@ import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { StatsLine } from './chat/StatsLine.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { fileMutationToolview } from './toolviews/file-mutation-row.tsx' +import { webToolview } from './toolviews/web-row.tsx' import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { todoToolview } from './toolviews/todo-row.tsx' import { askQuestionToolview } from './toolviews/ask-question-row.tsx' @@ -52,6 +54,10 @@ const ABSENT_LEXICON = { getSnapshot: () => EMPTY_LEXICON, subscribe: () => () => {}, } +const ABSENT_MENU_LAUNCHER = { + getSnapshot: (): string | null => null, + subscribe: () => () => {}, +} /** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { @@ -86,6 +92,11 @@ export function apply(ctx: Context): void { // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() + // Chat scroll offsets by session, surviving view switches (the chat view + // unmounts under the tab ring). Deliberately not persisted: a fresh page + // load should keep the open-jump-to-bottom default. + const chatScrollTops = new Map() + const viewTabs = (): ViewTab[] => { const tabs: ViewTab[] = [] for (const entry of slots.entries('conversation.view')) { @@ -187,14 +198,28 @@ export function apply(ctx: Context): void { if (sessionId === undefined) { return { keyboard: undefined, + toggleCommandMenu: undefined, stop: undefined, command: undefined, - hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON }, + hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER }, } } const shell = inputHub.shell(sessionId) + const slash = inputHub.slash(sessionId) return { keyboard: shell, + toggleCommandMenu: slash === undefined + ? undefined + : (selection) => { + shell.dismissPopup() + const snapshot = shell.snapshot + slash.toggleSource('command', { + trigger: '/', + query: '', + position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline', + span: { ...selection, draftRev: snapshot.draftRev }, + }) + }, stop: () => { scopedConversation(sessions, sessionId).cancel().catch(() => { // Stop failure surfaces via snapshot.promptError; nothing to restore. @@ -206,7 +231,11 @@ export function apply(ctx: Context): void { const result = await session.command(line) return result.ok && result.value.matched }, - hooks: { notices: shell.notices, lexicon: shell.lexicon }, + hooks: { + notices: shell.notices, + lexicon: shell.lexicon, + menuLauncher: slash?.launcher ?? ABSENT_MENU_LAUNCHER, + }, } }, }, InputBar) @@ -252,6 +281,19 @@ export function apply(ctx: Context): void { }) }, loadOlder: () => { void scoped.loadOlder() }, + // Unregistered 'trajectory' id is safe: the tab ring falls back to + // the first view, and the untouched inspect target stays inert. + inspectCall: (callId) => { + actions.setInspect({ callId }) + actions.setView('trajectory') + }, + chatScroll: { + save: (top) => { + if (top === null) chatScrollTops.delete(sessionId) + else chatScrollTops.set(sessionId, top) + }, + read: () => chatScrollTops.get(sessionId) ?? null, + }, forkAt: (seq) => { sessions.fork({ sessionId, atSeq: seq, increaseTitle: true }) .then((childId) => { sessions.open(childId) }) @@ -278,6 +320,15 @@ export function apply(ctx: Context): void { // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions). ctx.plugin(bashToolviewSample) + // The write/edit rows ride the same seam: a file-mutation call declares the + // diff render intent, so these rows stack the applied diff card under their + // path-link summary (the terminal card's posture, applied to diffs). + ctx.plugin(fileMutationToolview) + // The web rows ride the same seam: one WebRow registered under both + // web_search and web_fetch, rendering the completed retrieval's web card + // resident under the summary (a product registration, not a sample). + ctx.plugin(webToolview) + // The todo_write row rides the same seam (a product registration, not a sample). ctx.plugin(todoToolview) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index b328404518..387a7fd82a 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -64,7 +64,6 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass summary={firstLine(text)} body={text} state={running ? 'running' : 'ok'} - expandOnRowClick /> ) } diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index f8536e5fe3..d509a2e521 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -46,6 +46,8 @@ function scrollerOf(from: HTMLElement): HTMLElement { type OpenFile = (path: string) => void +type InspectCall = (callId: string) => void + /** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */ type RenderToolRow = ChatViewSlotProps['renderSlot'] @@ -53,23 +55,36 @@ type RenderToolRow = ChatViewSlotProps['renderSlot'] * chat view narrows once to the runtime snapshot the binding actually feeds. */ type UseConversation = SnapshotSelectorHook +function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null { + if (!running) return null + for (let index = nodes.length - 1; index >= 0; index -= 1) { + const node = nodes[index] + if (node === undefined) continue + if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq + if (node.kind === 'assistant' || node.kind === 'user') return null + } + return null +} + /** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a * top-level call (same registrations, same fallback), nested by the parent. * A started-but-unsettled sub-call arrives as the RunningToolCall shape and * renders the running state exactly as a native in-flight row. */ -const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, t }: { +const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall, t }: { renderSlot: RenderToolRow node: CodeSubCall openFile: OpenFile selected: boolean cwd: string | undefined + inspectCall: InspectCall t: ChatViewSlotProps['t'] }) { const settled = 'kind' in node const toolName = settled ? node.call?.name ?? '' : node.name const owner = useMemo(() => ({ callId: node.callId, toolName, block: node, openFile, cwd, - }), [node, toolName, openFile, cwd]) + inspect: () => { inspectCall(node.callId) }, + }), [node, toolName, openFile, cwd, inspectCall]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -86,7 +101,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select * renders its logged sub-dispatches as always-visible indented rows — * each one the same keyed-slot dispatch as a native top-level call. */ const CallRow = memo(function CallRow({ - renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, t, + renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall, t, }: { renderSlot: RenderToolRow callId: string @@ -101,11 +116,13 @@ const CallRow = memo(function CallRow({ selectedCallId?: string | undefined /** Session workspace root for path-relative summaries. */ cwd: string | undefined + inspectCall: InspectCall t: ChatViewSlotProps['t'] }) { const owner = useMemo(() => ({ callId, toolName, block, openFile, cwd, - }), [callId, toolName, block, openFile, cwd]) + inspect: () => { inspectCall(callId) }, + }), [callId, toolName, block, openFile, cwd, inspectCall]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -122,6 +139,7 @@ const CallRow = memo(function CallRow({ openFile={openFile} selected={node.callId === selectedCallId} cwd={cwd} + inspectCall={inspectCall} t={t} /> ))} @@ -132,7 +150,7 @@ const CallRow = memo(function CallRow({ }) /** Consecutive tool results as one step-run group (uniform 16px rhythm). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, t }: { +const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall, t }: { renderSlot: RenderToolRow results: readonly ToolResultNode[] openFile: OpenFile @@ -142,6 +160,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec codeDispatches: ReadonlyMap /** Session workspace root for path-relative summaries. */ cwd: string | undefined + inspectCall: InspectCall t: ChatViewSlotProps['t'] }) { return ( @@ -158,6 +177,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec subCalls={codeDispatches.get(node.callId)} selectedCallId={selectedCallId} cwd={cwd} + inspectCall={inspectCall} t={t} /> ))} @@ -237,7 +257,9 @@ function StreamingTail({ useSession, onGrow, t }: { * The chat view slot entry: pure component over the composed props (tool rows * render through the declared keyed hole's renderSlot share). */ -export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt, t }: ChatViewSlotProps) { +export function ChatView({ + useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, +}: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) @@ -251,6 +273,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const selectedCallId = useStore(s => s.selection?.callId) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) + const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running]) // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) @@ -284,10 +307,20 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */ if (local === null) return const el = scrollerOf(local) - // Open completed: jump to the bottom once. + // Open completed: jump to the bottom once — unless a scroll position + // survives from a previous mount (view-tab switch away and back), which + // is restored instead of snapping the reader back to the floor. if (openState === 'open' && !openedRef.current) { openedRef.current = true - toBottom(el) + const saved = chatScroll.read() + if (saved === null) { + toBottom(el) + } else { + el.scrollTop = saved + const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 + atBottomRef.current = isAtBottom + setAtBottom(isAtBottom) + } firstSeqRef.current = firstSeq lastKeyRef.current = lastKey followSigRef.current = followSig @@ -325,6 +358,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1 atBottomRef.current = isAtBottom setAtBottom(isAtBottom) + // Continuous save (unmount happens after ref detach, so saving there is + // too late); pinned-to-bottom clears so a remount keeps following. + chatScroll.save(isAtBottom ? null : el.scrollTop) } // Bind scroll to the resolved scrollport (host or local) once per mount. @@ -375,6 +411,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio selectedCallId={inGroup ? selectedCallId : undefined} codeDispatches={codeDispatches} cwd={cwd} + inspectCall={inspectCall} t={t} /> ) @@ -399,7 +436,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio } /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ if (node.kind === 'tool-result') return null - return + return ( + + ) } return ( @@ -435,6 +480,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio subCalls={codeDispatches.get(call.callId)} selectedCallId={selectedCallId} cwd={cwd} + inspectCall={inspectCall} t={t} /> ))} diff --git a/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx b/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx index 0b1e9ea1b0..361fb24517 100644 --- a/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/DisclosureRow.tsx @@ -14,6 +14,8 @@ export interface DisclosureRowProps { expandOnRowClick?: boolean | undefined /** Replaces the collapsed icon with a chevron while the row is hovered. */ previewChevron?: boolean | undefined + /** Keeps `collapsedContent` inline while open (ToolRow's summary stays readable next to the expanded card). */ + keepContentWhenOpen?: boolean | undefined collapsedContent?: ReactNode children?: ReactNode className?: string | undefined @@ -36,6 +38,7 @@ export function DisclosureRow({ onToggle, expandOnRowClick = false, previewChevron = expandable, + keepContentWhenOpen = false, collapsedContent, children, className, @@ -93,7 +96,7 @@ export function DisclosureRow({ )} {title} - {!open && collapsedContent} + {(keepContentWhenOpen || !open) && collapsedContent}
{open && children}
diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index 8f5d6775e4..aa236c87ca 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -34,7 +34,7 @@ export function GenericCommandCard({ node, t }: GenericCommandCardProps) { } + icon={} title={title} summary={summary} // Expandable only when the outcome text overflows a one-line summary. diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css new file mode 100644 index 0000000000..13a4ef9d62 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css @@ -0,0 +1,15 @@ +/* The generic card grows a resident web card under its summary row when the + tool declares the `web` render intent but has no keyed row of its own (the + web_search/web_fetch rows register their own WebRow). A column around the + ToolRow keeps the row's own 24px height. */ + +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.web { + margin: 4px 0 4px 22px; +} diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 5f9e364bab..faadcf090b 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -7,12 +7,15 @@ import type { ReactNode } from 'react' import { IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16, - IconThinkOutline14, + IconThinkOutline14, WebBlock, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts' -import { terminalCardModel } from '../contract/terminal-card-model.ts' +import { diffCardModel } from '../contract/diff-card-model.ts' +import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts' import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts' import { ToolRow } from './ToolRow.tsx' +import css from './GenericToolCard.module.css' /** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */ const VARIANT_ICONS: Record = { @@ -31,11 +34,18 @@ export interface GenericToolCardProps extends ToolRowOwnerProps { t: ChatViewSlotProps['t'] } -export function GenericToolCard({ toolName, block, cwd, openFile, t }: GenericToolCardProps) { +export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) { const model = toolRowModel(toolName, block, cwd) const terminal = terminalCardModel(block, cwd) + const diff = diffCardModel(block) + const web = webCardModel(block) + // A failing exit status is the terminal card's own error signal (the call + // itself settles isError:false), surfaced as the row's red state dot. + const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal) + ? 'error' + : model.state const singleFile = model.filePath !== undefined - return ( + const row = ( ) + // A web-declaring tool without its own keyed row lands here; its card is + // resident under the summary, mirroring WebRow (and BashRow's terminal card). + if (web === null) return row + return ( +
+ {row} + +
+ ) } diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index baa32d3228..308101f3c8 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -109,6 +109,106 @@ line-height: 24px; } +.retryRow { + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; +} + +.retrySummary { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 2px 0; + gap: 7px; + border-radius: 3px; + color: inherit; + cursor: pointer; + list-style: none; + user-select: none; +} + +.retrySummary::-webkit-details-marker { + display: none; +} + +.retrySummary::after { + width: 6px; + height: 6px; + border-right: 1.5px solid currentcolor; + border-bottom: 1.5px solid currentcolor; + content: ''; + opacity: 0.8; + transform: rotate(-45deg); + transition: transform 120ms ease; +} + +.retrySummary:hover { + color: var(--dsw-alias-label-secondary); +} + +.retrySummary:focus-visible { + outline: 1.5px solid var(--dsw-alias-button-info-fill); + outline-offset: 2px; +} + +.retryText { + color: inherit; +} + +.retryRow[data-active] .retryText { + background: + linear-gradient( + 90deg, + var(--dsw-alias-label-tertiary) 0%, + var(--dsw-alias-label-tertiary) 40%, + var(--dsw-alias-label-secondary) 50%, + var(--dsw-alias-label-tertiary) 60%, + var(--dsw-alias-label-tertiary) 100% + ); + background-position: 100% 50%; + background-size: 200% 100%; + background-clip: text; + color: transparent; + animation: retry-shimmer 1.6s ease-in-out infinite; +} + +.retryRow[open] .retrySummary::after { + transform: rotate(45deg); +} + +.retryDetails { + display: grid; + gap: 2px; + margin-top: 3px; + padding-left: 14px; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 18px; +} + +.retryDetailLabel { + color: var(--dsw-alias-label-secondary); +} + +@keyframes retry-shimmer { + from { + background-position: 100% 50%; + } + + to { + background-position: 0 50%; + } +} + +@media (prefers-reduced-motion: reduce) { + .retryRow[data-active] .retryText { + background: none; + color: inherit; + animation: none; + } +} + /* Reference chip projection inside a user bubble (`name` model spans render as chips; free geometry — no textarea pairing here). */ .refChip { diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 26bba4728a..7dea5eb58a 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,13 +1,12 @@ -// MessageItem: the five simple node kinds — user bubble (right-aligned, with +// MessageItem: simple chat nodes — user bubble (right-aligned, with // clock + copy / branch / edit IconActions), steering (badged bubble), context -// injection, the compaction marker, and unknown-surface JSON rows. Props are -// frozen node slices off the snapshot cache; memo holds across streaming -// because unchanged nodes keep their references. +// injection, compaction marker, retry disclosure, and unknown-surface JSON rows. -import { memo } from 'react' +import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { - CompactionSummaryNode, ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, + CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode, + UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' @@ -17,7 +16,8 @@ import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' export interface MessageItemProps { - node: UserMessageNode | SteeringMessageNode | ContextMessageNode | CompactionSummaryNode | UnknownSurfaceNode + node: UserMessageNode | SteeringMessageNode | ContextMessageNode | CompactionSummaryNode | ModelRetryNode | UnknownSurfaceNode + retryActive?: boolean /** Fork the session through the turn containing this message (user-bubble branch action). */ onFork?: (seq: number) => void /** The owning view's locale seat, passed down as a plain prop. */ @@ -35,6 +35,80 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown return { text: texts.join(''), rest } } +function retrySeconds(milliseconds: number): number { + return Math.max(1, Math.ceil(milliseconds / 1_000)) +} + +interface RetryCountdown { + deadline: number + seconds: number +} + +function ModelRetryItem({ node, active, t }: { + node: ModelRetryNode + active: boolean + t: ChatViewSlotProps['t'] +}) { + // Anchor the host-scheduled delay to this browser's first render of the + // retry node. Host event time and Date.now() may belong to different clocks. + const deadline = useMemo(() => Date.now() + node.delayMs, [node.delayMs, node.seq]) + const scheduledSeconds = retrySeconds(node.delayMs) + const maximum = node.mode === 'normal' ? node.maxRetries : '∞' + const [countdown, setCountdown] = useState(() => ({ + deadline, + seconds: retrySeconds(deadline - Date.now()), + })) + const remainingSeconds = countdown.deadline === deadline + ? countdown.seconds + : retrySeconds(deadline - Date.now()) + + useEffect(() => { + if (!active) return + const updateCountdown = (): number => { + const next = retrySeconds(deadline - Date.now()) + setCountdown(current => ( + current.deadline === deadline && current.seconds === next + ? current + : { deadline, seconds: next } + )) + return next + } + if (updateCountdown() === 1) return + const timer = window.setInterval(() => { + if (updateCountdown() === 1) window.clearInterval(timer) + }, 250) + return () => { window.clearInterval(timer) } + }, [active, deadline]) + + const label = active + ? t('message.retry.active') + : node.retryState === 'cancelled' + ? t('message.retry.cancelled') + : node.retryState === 'started' + ? t('message.retry.started') + : t('message.retry.scheduled') + const seconds = active ? remainingSeconds : scheduledSeconds + + return ( +
+ + + {t('message.retry.status', { label, retry: node.retry, maximum, seconds })} + + +
+
+ {t('message.retry.delay')} + {Math.round(node.delayMs)}ms +
+
+ {t('message.retry.failure')} + {node.failure.message} +
+
+
+ ) +} /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The @@ -67,7 +141,9 @@ function projectUserText(text: string): ReactNode { return <>{parts} } -export const MessageItem = memo(function MessageItem({ node, onFork, t }: MessageItemProps) { +export const MessageItem = memo(function MessageItem({ + node, retryActive = false, onFork, t, +}: MessageItemProps) { const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { case 'user': { @@ -108,6 +184,8 @@ export const MessageItem = memo(function MessageItem({ node, onFork, t }: Messag ) case 'compaction': return + case 'model-retry': + return default: return (
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index e53b472c50..030a6f0d80 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -56,6 +56,10 @@ background: var(--dsw-alias-state-business-primary); } +.chevron { + color: var(--dsw-alias-label-secondary); +} + .title { font-weight: 400; } @@ -103,8 +107,65 @@ text-decoration: underline; } -/* Expanded body: pad-left 22 indented gray text, no border, no fill. */ -.body { +/* Error row's collapsed summary: the failure's first line in the error color. */ +.errorSummary { + color: var(--dsw-alias-state-error-primary); +} + +/* Expanded body + Inspect pill wrapper (sibling of .row: clicks never toggle). */ +.bodyWrap { + display: flex; + flex-direction: column; +} + +/* Hover-revealed jump to the trajectory record: a small pill in real flow + under the expanded body's bottom-left corner (it reserves its line, so + revealing never shifts layout); revealed by hovering anywhere on the tool + call — title row included — or by keyboard focus. */ +.inspectButton { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 4px; + margin: 4px 0 2px 4px; + padding: 2px 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + /* Base background, not bg-overlay: the overlay token is a raised dark + surface and reads too heavy for a quiet in-flow affordance. */ + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease; +} + +.root:hover .inspectButton, +.inspectButton:focus-visible { + opacity: 1; +} + +/* Solid hover fill (a translucent token would let content bleed through). */ +.inspectButton:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); + color: var(--dsw-alias-label-primary); +} + +/* Expanded-body scroll wrapper for the run_code CodeBlock; the IN/OUT card + and the terminal card scroll INSIDE their own surface instead, so the + scrollbar sits within the rounded card. */ +.bodyScroll { + max-height: 260px; + overflow-y: auto; +} + +/* Think expanded body: plain indented gray reasoning prose — no IN/OUT card + (the reasoning is not an input payload), pre-wrapped at the row's indent. + Uncapped: reasoning reads as message prose, so it flows with the page + instead of scrolling in a box. */ +.thinkBody { padding: 4px 0 4px 22px; font-size: 14px; line-height: 24px; @@ -113,6 +174,78 @@ color: var(--dsw-alias-label-tertiary); } +/* Expanded input/output card (figma 1249:35657): the code-block surface and + radius from the TerminalBlock/CodeBlock family. The card itself is a plain + column — the padding and the IN/OUT gutter-label grid live on each section + so the divider spans the full card width and each section scrolls alone. */ +.ioCard { + display: flex; + flex-direction: column; + margin: 4px 0 4px 4px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-markdown-code-block); + font: var(--dsw-font-markdown-code-block-small); +} + +/* One card section (IN or OUT): the gutter-label grid, capped and scrolling + independently so a long input never buries a short output (and vice versa). */ +.ioSection { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 14px; + align-items: baseline; + padding: 12px 16px; + max-height: 150px; + overflow-y: auto; +} + +/* Card-internal scrollbar: a 2px transparent border clips the thumb inward so + it floats off the rounded card edge instead of hugging it (the terminal + card's own output scroller carries the same treatment in TerminalBlock). */ +.ioSection::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 6px; +} + +/* Track end-margins keep the thumb's travel out of the rounded corners. */ +.ioSection::-webkit-scrollbar-track { + margin: 6px 0; +} + +/* Caption (not tertiary): one step dimmer than the payload text so the + gutter labels read as labels, not as part of the content. Sticky against + the section's own scroll so the label stays readable while its payload + scrolls underneath (top 0 = the section's padding edge inside the + scrollport; start-aligned because sticky needs a block-start anchor). */ +.ioLabel { + position: sticky; + top: 0; + align-self: start; + color: var(--dsw-alias-label-caption); +} + +/* l2 hairline between the IN and OUT sections, spanning the full card width + (it sits between the padded sections, not inside their grid). */ +.ioDivider { + flex: none; + height: 1px; + background: var(--dsw-alias-border-l2); +} + +.ioText { + min-width: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--dsw-alias-label-secondary); +} + +/* A failed call's OUT text shares the collapsed summary's error color. */ +.ioText[data-error] { + color: var(--dsw-alias-state-error-primary); +} + /* The two block-shaped expanded bodies: the code variant's run_code program through CodeBlock (shiki-highlighted TypeScript) and a terminal card's command output through TerminalBlock. Both are drawn by the shared @@ -121,15 +254,27 @@ flow's row rhythm. */ .codeBody, .terminalBody { - margin: 4px 0 4px 22px; + margin: 4px 0 4px 4px; } -/* Indented to the body's own column so the description reads as the card's - heading rather than as another summary row, and sits tight against the card - below it. Its own rule: grouping it with a body would put description - typography on a `CodeBlock` wrapper and change that body's spacing. */ -.terminalDescription { - margin: 4px 0 0 22px; - color: var(--dsw-alias-label-secondary); - font: var(--dsw-font-xs-13); +/* A write/edit diff renders through DiffBlock; like the terminal card it draws + its own surface, so only the row indentation is this file's concern. */ +.diffBody { + margin: 4px 0 4px 4px; +} + +/* In-row code renders at the smaller code size (12/18) via each primitive's + rebindable content-font seam; standalone markdown code blocks keep 13/22. */ +.codeBody { + --dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small); +} + +/* The terminal card scrolls its OUTPUT inside its own surface (same l1 + hairline as the IN/OUT card): the banner stays pinned and the scrollbar + never rides over it. 224px = the 260px card cap minus the ~36px banner. */ +.terminalBody { + --dsl-terminal-font: var(--dsw-font-markdown-code-block-small); + --dsl-terminal-line-height: 18px; + --dsl-terminal-output-max-height: 224px; + border: 1px solid var(--dsw-alias-border-l1); } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 0be424a96b..40a5824ce3 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -1,17 +1,27 @@ // ToolRow: the single-line tool summary row (figma component set 122:9479) — // 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title + -// separator dot + FILL-truncated summary. The collapsed row is always one -// line; the expanded body is indented gray text, the run_code program through -// CodeBlock, or — for a call whose render intent is a terminal card — the -// command's own output through TerminalBlock, capped at -// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is -// component-local view state. File-tool summaries are path links that open -// through the host; the row itself is not a details-panel control. +// separator dot + FILL-truncated summary, drawn through the shared +// DisclosureRow chrome with the whole row as the expand toggle (click / +// Enter / Space, icon→chevron hover preview). The collapsed row is always +// one line; every row with body, output, or terminal material is expandable; +// the summary stays inline while open, except Think, whose body opens with +// the same first line and would repeat it. +// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for +// text input/output, the run_code program through CodeBlock, or a terminal +// card's command output through TerminalBlock — lives in a max-height scroll +// container so a long payload scrolls internally instead of taking over the +// message flow; Think's prose is the exception and flows uncapped like +// message text. Expand state is component-local view state. File-tool +// summaries are path links that open through the host (stopPropagation keeps +// the two gestures independent); an error row's collapsed summary is the +// failure's first line in the error color. import { useState, type MouseEvent, type ReactNode } from 'react' -import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import clsx from 'clsx' +import { CodeBlock, DiffBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts' +import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts' +import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts' import { DisclosureRow } from './DisclosureRow.tsx' import css from './ToolRow.module.css' @@ -26,18 +36,27 @@ export interface ToolRowProps { icon: ReactNode title: string summary: string - /** Expanded-body text; null = no text body (`terminal` is the other body source). */ + /** Expanded-body input text; null = no input section. */ body: string | null + /** Flattened result text for the expanded Output section; null/absent = no output section. */ + output?: string | null | undefined + /** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */ + errorSummary?: string | null | undefined /** * Terminal-card material for a call whose render intent is a terminal card - * (derived by `terminalCardModel`); it replaces the text body when present. - * Null or absent leaves the text body, and a row with neither is not - * expandable (its leading slot never toggles). + * (derived by `terminalCardModel`); it replaces the text sections when + * present. A row with no body, no output, and no terminal material is not + * expandable. */ terminal?: TerminalCardModel | null | undefined + /** + * Diff-card material for a call whose render intent is a diff card (derived by + * `diffCardModel`); it replaces the text body when present, the same way + * `terminal` does. A call carries at most one card intent, so the two are + * never both set. + */ + diff?: DiffCardModel | null | undefined state: ToolRowState - /** Makes the row itself the expand control instead of only its leading icon. */ - expandOnRowClick?: boolean | undefined /** * Filesystem path from tool args; when set with onOpenFile, the summary * renders as a hover-underline link that opens the host default app. @@ -45,6 +64,21 @@ export interface ToolRowProps { filePath?: string | undefined /** Open the path with the host OS default application (already cwd-resolved). */ onOpenFile?: ((path: string) => void) | undefined + /** + * Jump to this call in the trajectory view: a hover-revealed Inspect pill + * over the expanded body. Absent = no affordance (rows without a call + * identity, like Think). + */ + inspect?: (() => void) | undefined +} + +/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */ +function IconInspect() { + return ( + + + + ) } /** Leading-slot state substitution: the tool icon yields to the terminal state @@ -66,26 +100,27 @@ export function ToolRow({ title, summary, body, + output, + errorSummary, terminal, + diff, state, - expandOnRowClick = false, filePath, onOpenFile, + inspect, }: ToolRowProps) { const [expanded, setExpanded] = useState(false) const terminalBody = terminal ?? null - // A row that names a single file keeps one interaction (open that path); - // args expand is off whether or not the open callback is wired yet. Terminal - // material still expands: only the file variants carry a path, so a terminal - // card and a file link never land on the same row. - const singleFile = filePath !== undefined - const fileLink = singleFile && onOpenFile !== undefined - const expandable = (body !== null && !singleFile) || terminalBody !== null - // The text arms take the empty string for a null body: a row expandable - // only through its terminal material renders the terminal body instead, so - // this substitution never shows. - const text = body ?? '' + const diffBody = diff ?? null + const outputText = output ?? null + const expandable = body !== null || outputText !== null || terminalBody !== null || diffBody !== null const open = expanded && expandable + // An error row's collapsed summary IS the failure: the first error line in + // the error color outranks both the args summary and a terminal description. + const failureLine = state === 'error' ? errorSummary ?? null : null + const summaryText = failureLine ?? summary + // The failure line is error prose, not the path: no open-file affordance. + const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null const toggleExpand = () => { setExpanded(v => !v) } @@ -93,20 +128,33 @@ export function ToolRow({ event.stopPropagation() if (filePath !== undefined) onOpenFile?.(filePath) } + // Think reasoning is prose, not an input payload: expanded, it renders as + // plain indented text (no IN/OUT card) and the inline summary — the body's + // own first line — yields to avoid repeating itself. + const isThink = variant === 'think' + // The code variant's program renders through CodeBlock (shiki), so only its + // output joins the IN/OUT card; every other variant's input does too. + const cardBody = variant === 'code' ? null : body + // The state substitution rides the idle icon slot, so an expandable error + // row keeps DisclosureRow's icon→chevron hover preview (its default) instead + // of losing it with the icon. return (
{fileLink ? ( @@ -115,31 +163,73 @@ export function ToolRow({ className={css.fileLink} onClick={openFile} > - {summary} + {summaryText} ) : ( - {summary} + + {summaryText} + )} )} > - {/* The terminal presenter's description belongs above the card per - the render-intent contract. */} - {terminalBody?.description !== undefined && ( -
{terminalBody.description}
- )} - {terminalBody !== null - ? ( - - ) - : variant === 'code' - ? - :
{text}
} + {/* The wrapper (sibling of the header row, so clicks inside never + toggle it) carries the expanded body and the Inspect pill below. */} +
+ {terminalBody !== null + ? ( + + ) + : diffBody !== null + ? + : isThink + ?
{body}
+ : ( + <> + {variant === 'code' && body !== null && ( +
+ +
+ )} + {(cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ IN + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ OUT + + {outputText} + +
+ )} +
+ )} + + )} + {inspect !== undefined && ( + + )} +
) diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index da0a0fe9e7..4958894154 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -1,7 +1,8 @@ /** * Chat flow derivation: ConversationSnapshot nodes -> render items. Tool * results group into consecutive-run tool groups (figma step-summary flow, - * VERTICAL gap10) alternating with narration; everything else passes through. + * VERTICAL gap10) alternating with narration. Consecutive retry notices + * reuse the first notice's row while projecting the latest retry turn. * Item identity keys are stable across snapshots so the list parent can * subscribe to keys only while rows subscribe to content. IconActions ownership * (last content assistant per turn) is derived here too so ChatView and the @@ -48,8 +49,8 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon /** * Group finalized nodes into the step-summary flow. - * @param nodes - snapshot nodes (human transcript order). - * @returns flow items; consecutive tool-results merged into one group keyed by the first seq. + * @param nodes - snapshot nodes in human-transcript and durable-notice order. + * @returns flow items; consecutive tool results group and retry notices reuse their first key. */ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] { const items: ChatFlowItem[] = [] @@ -63,6 +64,17 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem } else { group.push(node) } + } else if (node.kind === 'model-retry') { + group = null + const previous = items[items.length - 1] + if ( + previous?.kind === 'node' + && previous.node.kind === 'model-retry' + ) { + items[items.length - 1] = { ...previous, node } + } else { + items.push({ kind: 'node', key: `n${node.seq}`, node }) + } } else { group = null items.push({ kind: 'node', key: `n${node.seq}`, node }) diff --git a/packages/client/ui-conversation/src/client/contract/diff-card-model.ts b/packages/client/ui-conversation/src/client/contract/diff-card-model.ts new file mode 100644 index 0000000000..bc914e4820 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/diff-card-model.ts @@ -0,0 +1,100 @@ +/** + * Pure derivation of the diff-card props from a frozen call slice: the + * `card:'diff'` render intent the write/edit tools declare arrives on the + * snapshot as `callView`/`resultView`, and this is the one place that turns + * that pair into what {@link DiffBlock} draws. Both conversation render sites + * (the chat tool row's expanded body and the details panel's Output section) + * call this, so the hunks they show are derived once. + * @module + */ +import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallBlock } from './tool-call-model.ts' + +/** + * Diff-body lines the chat row shows before collapsing the middle — half the + * primitive's own default, which the details panel keeps. A chat row is a + * summary surface inside the message flow: the flow must stay scannable across + * many calls, while the details panel is the single-call reading surface. The + * same split {@link CHAT_TERMINAL_MAX_LINES} draws for a terminal card, so the + * two card kinds cap a long body at the same place in the flow. A design + * constant of this UI's row geometry, not a deployment choice. + */ +export const CHAT_DIFF_MAX_LINES = 8 + +/** + * The {@link DiffBlock} props this derivation owns. Picked off the primitive's + * props so the two stay in step; `maxLines`/`className` belong to each render + * site. + */ +export interface DiffCardModel { + /** + * The props {@link DiffBlock} draws. Held as a nested object so a render site + * spreads exactly the primitive's own surface and can never leak a + * neighbouring field into it. + */ + card: Pick +} + +/** + * Narrow a wire `card:'diff'` view's `diffs` to well-formed hunks. The event + * view crosses the wire and `toolEventViewSchema` validates only the `card` + * string, so a version mismatch or an anomalous plugin can deliver a `diff` card + * whose `diffs` is absent, not an array, or carries malformed hunks. Returning + * null for any of those routes the block to the generic path instead of letting + * DiffBlock's `for...of`/`split` throw and crash the row or the details panel. + * @param diffs - the view's `diffs` field, unverified. + * @returns the validated hunks, or null when the payload is not usable. + */ +function narrowDiffs(diffs: unknown): DiffHunk[] | null { + if (!Array.isArray(diffs) || diffs.length === 0) return null + const out: DiffHunk[] = [] + for (const hunk of diffs) { + if (typeof hunk !== 'object' || hunk === null) return null + const { path, oldText, newText } = hunk as Record + if (typeof path !== 'string') return null + if (oldText !== null && typeof oldText !== 'string') return null + if (typeof newText !== 'string') return null + out.push({ path, oldText, newText }) + } + return out +} + +/** + * Derive the diff-card props for a tool call, or null when this call is not a + * diff card and belongs on the generic path. + * + * The result side is authoritative once the call settles: the write/edit tools + * return the applied contextual hunks there (an edit's real before/after, a + * create's whole-file diff), which replace the call-time diff derived from the + * arguments alone. While the call is still running only the call side exists, + * so a running write/edit shows its intended change. Null is the documented + * generic-card default and covers every non-diff card — including a `card` + * value this UI version does not know, which arrives over the wire and cannot + * be trusted to be one of the compiled variants — and a settled call whose + * result view is generic (how write/edit keep their execution errors on the + * generic path). + * + * This derivation consumes only `diffs`; the render intent's `title` field is + * deliberately dropped. The row supplies its own title (`Edit`/`Write · path` + * from the args) and that outranks the view's `title`, matching the TUI diff + * branch, which likewise draws no view title. A tool that names its own diff + * header therefore does not surface that text on the Web row — an accepted + * product choice, recorded here as the one asymmetry with the terminal card, + * whose derivation does consume the view's title. + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the diff-card props, or null for the generic path. + */ +export function diffCardModel(block: ToolCallBlock): DiffCardModel | null { + if (!('kind' in block)) { + // Running: the call view may carry the intended diff; the result is absent. + const call = block.callView?.card === 'diff' ? block.callView : null + const diffs = call === null ? null : narrowDiffs(call.diffs) + return diffs === null ? null : { card: { diffs } } + } + // Settled: the result view's applied hunks replace the call-time diff. A + // window that dropped the call head leaves only the result, which still + // renders — the result view carries the whole change. + const result = block.resultView?.card === 'diff' ? block.resultView : null + const diffs = result === null ? null : narrowDiffs(result.diffs) + return diffs === null ? null : { card: { diffs } } +} diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index bb9c540fbb..b909b6e979 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -5,7 +5,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts' +import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -147,13 +147,17 @@ export interface InputZone { } /** - * View-slot owner share: deliberately empty — ConversationRoot supplies - * nothing at its renderSlot site (sessionId and the snapshot hook arrive as + * View-slot owner share: the cross-view inspect handoff (otherwise views need + * nothing from the render site — sessionId and the snapshot hook arrive as * framework-standard props; tool rows go through each view's own declared - * toolview hole). Kept as the named owner seat so a future cross-view - * payload has a home. + * toolview hole). */ -export interface ConvViewOwnerProps {} +export interface ConvViewOwnerProps { + /** One-shot inspect request from another view (chat's Inspect button); null when idle. */ + inspect?: { callId: CallId } | null + /** Acknowledge the inspect request once applied (clears the store field). */ + onInspectDone?: () => void +} /** * Owner share of a per-view toolview slot: the call material the rendering @@ -176,6 +180,11 @@ export interface ToolRowOwnerProps { * The chat view resolves relative paths against the session cwd. */ openFile: (path: string) => void + /** + * Jump to this call's record in the trajectory view (the expanded row's + * hover Inspect affordance). Undefined when no trajectory jump is wired. + */ + inspect?: (() => void) | undefined } /** @@ -265,14 +274,14 @@ export interface ComposerBarOwnerProps { rightItems?: ReactNode /** composer.dock entries (stats line), rendered under the card inside the bar's width column. */ footer?: ReactNode - onAdd?: () => void - addLabel?: string } /** Injected share of the composer-bar entry (package-internal faces). */ export interface ComposerBarInjected { /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */ keyboard: ComposerKeyboard | undefined + /** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */ + toggleCommandMenu: ((selection: EditSelection) => void) | undefined /** Cancel the in-flight turn; absent with the session. */ stop: (() => void) | undefined /** @@ -292,6 +301,8 @@ export interface ComposerBarInjected { notices: ObservableSnapshot /** Hot plain-text reference lexicon for the decoration scan (decision 21). */ lexicon: ObservableSnapshot> + /** Source name opened by the programmatic menu launcher, or null. */ + menuLauncher: ObservableSnapshot } } @@ -423,6 +434,19 @@ export interface ChatViewInjected { */ openFile: (path: string) => void loadOlder: () => void + /** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */ + inspectCall: (callId: CallId) => void + /** + * Per-session scroll memory surviving view switches (in-memory, never + * persisted): the view saves on every scroll and restores on remount; a + * fresh page load starts empty and keeps the open-jump-to-bottom default. + */ + chatScroll: { + /** Record the scroll offset; null clears it (pinned to bottom). */ + save: (top: number | null) => void + /** Last recorded offset, or null when pinned or never recorded. */ + read: () => number | null + } /** Fork the session through the turn containing the message at `seq`, then open the child. */ forkAt: (seq: number) => void } diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts index c2d3886910..f6c7f5a911 100644 --- a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts @@ -37,17 +37,6 @@ export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlo } } -/** - * Output lines the chat row's expanded terminal body shows before collapsing - * the middle — half the primitive's own default, which the details panel - * keeps. A chat row is a summary surface inside the message flow: the flow - * must stay scannable across many calls, while the details panel is the - * single-call reading surface. A design constant of this UI's row geometry, - * not a deployment choice, so it is fixed here rather than a plugin Config - * field. - */ -export const CHAT_TERMINAL_MAX_LINES = 8 - /** * The {@link TerminalBlock} props this derivation owns. Picked off the * primitive's props so the two stay in step; `home` is absent because the web @@ -70,6 +59,20 @@ export interface TerminalCardModel { description: string | undefined } +/** + * True when a settled terminal card reports a failing exit — a non-zero code + * or a terminating signal. The bash tool settles a failing command as a + * completed call (`isError` stays false: the exit status is result data), so + * this is the collapsed row's only failure signal; without it the red exit + * pill would be visible only after expanding the card. + * @param model - a derived terminal card. + * @returns whether the card's exit status is a failure. + */ +export function terminalFailed(model: TerminalCardModel): boolean { + const { exitCode, signal, running } = model.card + return running !== true && ((exitCode !== undefined && exitCode !== 0) || signal !== undefined) +} + /** * Resolve a terminal view's working directory the way the render-intent * contract assigns to the UI bridge: an absolute path is used as-is, a relative diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index b53ef95c01..d7735fdab2 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -1,14 +1,15 @@ /** * Pure row-model derivation for tool summary rows: variant classification, - * one-line summary and expanded-body text from the frozen call slice. This - * derivation reads the call ARGUMENTS only; a call whose render intent is a - * terminal card gets its expanded body from the views instead, through + * one-line summary, expanded-body text, and flattened result output from the + * frozen call slice. Input material comes from the call ARGUMENTS; output and + * error material from the settled result node. A call whose render intent is + * a terminal card gets its expanded body from the views instead, through * `terminalCardModel` in terminal-card-model.ts. */ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer // that produces the values). -import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -70,11 +71,34 @@ export interface ToolRowModel { * relative values against the session cwd before opening. */ filePath: string | undefined - /** Expanded-body text (pretty args); null = row not expandable. */ + /** Expanded-body input text (pretty args); null = no input section. */ body: string | null + /** Flattened result text ({@link resultText}); null while running or when the result carries no text. */ + output: string | null + /** First line of the result text on an error row; null for every other state. */ + errorSummary: string | null state: ToolRowState } +/** + * Flatten a settled result's content blocks to display text: text blocks + * verbatim, other block shapes as pretty JSON. Empty content on a failed call + * falls back to the structured error's `name: code` line. + * @param node - the settled result node. + * @returns the flattened result text (may be empty). + */ +export function resultText(node: ToolResultNode): string { + const parts: string[] = [] + for (const block of node.content) { + if (block.type === 'text') parts.push(block.text) + else parts.push(JSON.stringify(block, null, 2)) + } + if (parts.length === 0 && node.error !== undefined) { + parts.push(`${node.error.name}: ${node.error.code}`) + } + return parts.join('\n') +} + function parseArgs(argsRaw: string): unknown { try { return JSON.parse(argsRaw) @@ -192,12 +216,19 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin const summary = variant === 'others' && toolName !== '' && toolTitle === undefined ? `${toolName} · ${base}` : base + // The empty string is "no text" for both derived result fields: a settled + // call with blank content has nothing to expand, and a blank first line + // would erase the collapsed error row's summary slot. + const output = done ? (resultText(block) || null) : null + const errorSummary = state === 'error' && output !== null ? firstLine(output) : null return { variant, title: toolTitle ?? VARIANT_TITLES[variant], summary, filePath: deriveFilePath(variant, argsRaw), body: deriveBody(variant, argsRaw), + output, + errorSummary, state, } } diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index 9ef9515f19..a8da4121b9 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/packages/client/ui-conversation/src/client/contract/views.ts @@ -23,4 +23,10 @@ export interface ChatStoreState { draft: string /** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */ view: string | null + /** + * One-shot inspect handoff: chat writes the call to reveal, the trajectory + * view consumes it and acknowledges by clearing. Read with `?? null` — + * persisted snapshots from before this field rehydrate without it. + */ + inspect: { callId: CallId } | null } diff --git a/packages/client/ui-conversation/src/client/contract/web-card-model.ts b/packages/client/ui-conversation/src/client/contract/web-card-model.ts new file mode 100644 index 0000000000..f2b15e023a --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/web-card-model.ts @@ -0,0 +1,84 @@ +/** + * Pure derivation of the web-card props from a frozen call slice: the + * `card:'web'` render intent the `web_search`/`web_fetch` tools declare at + * result time arrives on the snapshot as `resultView`, and this is the one + * place that turns it into what {@link WebBlock} draws. Both conversation + * render sites (the chat tool row's resident/expanded body and the details + * panel's Output section) call this, so the sources and fetch summary they + * show are derived once. + * + * The web card is result-only by contract: those tools keep a generic pending + * call view, so there is nothing to derive while the call is still running and + * a running call always takes the generic path. + * @module + */ +import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallBlock } from './tool-call-model.ts' + +/** + * Sources the chat row's web body shows before collapsing the middle — half + * the primitive's own default, which the details panel keeps. A chat row is a + * summary surface inside the message flow: the flow must stay scannable across + * many calls, while the details panel is the single-call reading surface. A + * design constant of this UI's row geometry, not a deployment choice, so it is + * fixed here rather than a plugin Config field. + */ +export const CHAT_WEB_MAX_SOURCES = 8 + +/** + * Derive the web-card props for a tool call, or null when this call is not a + * web card and belongs on the generic path. + * + * The result side supplies the whole card: the sources and answer for a + * `search`, the URL and status for a `fetch`. Cases producing null, all of + * them the documented generic-card default: + * + * - A running call (no `resultView` yet): the web tools keep a generic pending + * card, so nothing web-shaped exists until the call settles. + * - A settled call whose result view is not a web card — including a `card` + * value this UI version does not know, which arrives over the wire and so + * cannot be trusted to be one of the compiled variants, and a generic result + * view (a web tool's error path returns the generic card, whose text the + * generic path preserves). + * - A web card whose `kind` this UI version does not know (a newer host's + * value): the wire cannot be trusted to be `search` or `fetch`, so it takes + * the generic path rather than rendering as a malformed fetch. + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the web-card props, or null for the generic path. + */ +export function webCardModel(block: ToolCallBlock): WebBlockProps | null { + // Running calls have no result view; the web card is result-only. + if (!('kind' in block)) return null + const result = block.resultView + if (result?.card !== 'web') return null + if (result.kind === 'search') { + return { + kind: 'search', + answer: result.answer, + sources: result.sources.map(source => ({ + url: source.url, + title: source.title, + snippet: source.snippet, + publishedAt: source.publishedAt, + })), + truncated: result.truncated, + } + } + // Discriminate `fetch` explicitly rather than treating it as the else of + // `search`: a `kind` this UI version does not know arrives over the wire from + // a newer host, and reading it as a fetch would draw an empty URL and + // `HTTP undefined`. It takes the generic path, the same wire-boundary default + // an unknown `card` tag takes above. The static union narrows `kind` to + // `'fetch'` here, but the runtime value is off the wire, so the guard and its + // null fallthrough are load-bearing despite the type. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (result.kind === 'fetch') { + return { + kind: 'fetch', + url: result.url, + statusCode: result.statusCode, + truncated: result.truncated, + } + } + return null +} diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 2641e0dcc4..7b8fa344d6 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -106,6 +106,17 @@ export class InputHub implements InputService { return this.shell(id) } + /** + * Resolve the optional slash controller for composer chrome that launches + * the shared candidate menu without typing a trigger. + * @param id - session id. + * @returns the resident controller, or undefined when ui-slash is absent. + */ + slash(id: SessionId): SlashController | undefined { + const actx = this.sessions().scope(id) + return actx === undefined ? undefined : this.controller(actx) + } + /** * Default sink: optimistic clear + prompt. The session is always a real * host entity (materialized when its workspace was picked), so there is diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 571d2cd288..ff4267f892 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -19,10 +19,15 @@ export const zh = { 'placeholder.unavailable': '会话不可用', 'placeholder.hero': '描述你想要构建的内容', 'placeholder.workspace': '选择一个工作区开始', - 'input.addAttachment': '添加附件', + 'input.commands': '命令', 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', + 'access.confirm.title': '确认启用 Full access?', + 'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', + 'access.confirm.acknowledge': '我已了解风险,并愿意继续', + 'access.confirm.cancel': '取消', + 'access.confirm.enable': '启用 Full access', 'hero.headline': '开始构建吧', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', @@ -51,6 +56,13 @@ export const zh = { 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', 'message.branch': '在新对话中分支', + 'message.retry.active': '正在重试模型请求', + 'message.retry.cancelled': '模型请求重试已取消', + 'message.retry.started': '已重试模型请求', + 'message.retry.scheduled': '等待重试模型请求', + 'message.retry.status': '{label}({retry}/{maximum}) · {seconds}s', + 'message.retry.delay': '重试延迟:', + 'message.retry.failure': '失败原因:', 'command.running': '执行中…', 'command.failed': '命令失败', 'command.done': '已完成', @@ -104,10 +116,15 @@ export const en = { 'placeholder.unavailable': 'Session unavailable', 'placeholder.hero': 'Describe what you want to build', 'placeholder.workspace': 'Choose a workspace to start', - 'input.addAttachment': 'Add attachment', + 'input.commands': 'Commands', 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.accessMode': 'Access mode, current: {name}', + 'access.confirm.title': 'Enable Full access?', + 'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.', + 'access.confirm.acknowledge': 'I understand the risks and want to continue', + 'access.confirm.cancel': 'Cancel', + 'access.confirm.enable': 'Enable Full access', 'hero.headline': 'Let\'s start building', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', @@ -136,6 +153,13 @@ export const en = { 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', 'message.branch': 'Branch into a new conversation', + 'message.retry.active': 'Retrying model request', + 'message.retry.cancelled': 'Model request retry cancelled', + 'message.retry.started': 'Retried model request', + 'message.retry.scheduled': 'Waiting to retry model request', + 'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s', + 'message.retry.delay': 'Retry delay: ', + 'message.retry.failure': 'Failure reason: ', 'command.running': 'Running…', 'command.failed': 'Command failed', 'command.done': 'Completed', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 46cc018179..0da60c036d 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -1,10 +1,21 @@ -/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */ +/* Figma .FileContainerText 1:791: the wrapper uses the shared dock inset + inside the composer card around the inset panel. */ .dock { box-sizing: border-box; flex: none; - width: 100%; - max-width: 776px; + width: calc( + 100% - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); + max-width: calc( + var(--dsh-composer-card-max-width) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); /* Flex gap still applies after this item; subtract it together with the design's overlap so the later composer paints over the queue edge. */ margin: 0 auto calc( diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 1bc6f75e85..e62e5ab389 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -73,7 +73,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps } return ( -
+
{queue.length > 1 && ( diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 34a652470e..16974880dd 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -1,21 +1,28 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' -import { Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerBarProps } from '../contract/slots.ts' import css from './PermissionSelect.module.css' +const FULL_ACCESS = 'danger-full-access' + /** * Display transform: kebab-case machine names render as title-case labels * (`workspace-write` → `Workspace Write`); non-kebab host-configured names - * pass through. Twin of the /permission popup's (client ui-permission) — the - * two permission surfaces must show the same text. + * pass through. Full access intentionally overrides the machine-name + * transform so both permission surfaces use the product label `Full access`; + * the warning body remains locale-aware. */ function displayName(name: string): string { if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') } +function optionLabel(option: PermissionSelectValue['options'][number]): string { + return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name) +} + export interface PermissionSelectProps { value: PermissionSelectValue | undefined locked: boolean @@ -27,49 +34,94 @@ export interface PermissionSelectProps { export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) { const [pick, setPick] = useState(null) const [open, setOpen] = useState(false) + const [confirmation, setConfirmation] = useState(null) + const [acknowledged, setAcknowledged] = useState(false) + + useEffect(() => { + if (!locked && value !== undefined) return + setOpen(false) + setAcknowledged(false) + setConfirmation(null) + }, [locked, value]) if (value === undefined) return null const currentValue = pick ?? value.currentValue const current = value.options.find(option => option.value === currentValue) - const busy = pick !== null + const busy = pick !== null || confirmation !== null const items: MenuEntry[] = value.options .filter(o => o.value !== 'custom') - .map(option => ({ id: option.value, label: displayName(option.name) })) + .map(option => ({ id: option.value, label: optionLabel(option) })) - const choose = (id: string): void => { - setOpen(false) - if (id === value.currentValue) return + const submit = (id: string): void => { setPick(id) void command(`/permission ${id}`) .catch(() => false) .then(() => { setPick(null) }) } + const choose = (id: string): void => { + setOpen(false) + if (id === value.currentValue) return + if (id === FULL_ACCESS) { + setAcknowledged(false) + setConfirmation(id) + return + } + submit(id) + } + + const closeConfirmation = (): void => { + setAcknowledged(false) + setConfirmation(null) + } + + const confirmFullAccess = (): void => { + if (locked || !acknowledged || confirmation === null) return + const id = confirmation + closeConfirmation() + submit(id) + } + return ( - { setOpen(false) }} - side="top" - anchor={ - - } - /> + <> + { setOpen(false) }} + side="top" + anchor={ + + } + /> + + ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 5d26aa4a5e..1e557a0c0a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,13 +1,24 @@ /* Todo strip in the composer context stack (Figma 9:959): tip surface, - 14px radius, status icons + secondary item labels. */ + 14px radius, status icons + secondary item labels. It shares the composer + card geometry and adds the dock inset on both sides. */ .root { box-sizing: border-box; flex: none; overflow: hidden; margin: 0 auto; - width: calc(100% - 88px); - max-width: 752px; + width: calc( + 100% - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); + max-width: calc( + var(--dsh-composer-card-max-width) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; background: var(--dsw-specific-tip); diff --git a/packages/client/ui-conversation/src/client/stores.ts b/packages/client/ui-conversation/src/client/stores.ts index 4c27a87ed8..8a1cd6f068 100644 --- a/packages/client/ui-conversation/src/client/stores.ts +++ b/packages/client/ui-conversation/src/client/stores.ts @@ -3,7 +3,7 @@ * The plugin creates its handle at apply time so identity follows the fiber. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' -import type { ChatStoreState, SelectionTarget } from './contract/views.ts' +import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.ts' /** Declared action shape used to give the exported factory a stable return type. */ type ChatActions = { @@ -12,6 +12,7 @@ type ChatActions = { clearDraft: (draft: ChatStoreState) => void restoreDraft: (draft: ChatStoreState, text: string) => void setView: (draft: ChatStoreState, view: string) => void + setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void } /** @@ -20,7 +21,7 @@ type ChatActions = { */ export function createChatStore(): EngineStoreHandle { return defineStore({ - init: (): ChatStoreState => ({ selection: null, draft: '', view: null }), + init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }), persist: 'dsh.conversation.chat', actions: { select: (d, target: SelectionTarget | null) => { d.selection = target }, @@ -30,6 +31,7 @@ export function createChatStore(): EngineStoreHandle { if (d.draft === '') d.draft = text }, setView: (d, view: string) => { d.view = view }, + setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target }, }, }) } diff --git a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx index e297387eee..bda937266e 100644 --- a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx @@ -1,7 +1,7 @@ // ask_user_question toolview: question-flavored summary row replacing the // generic "Tool call" card, registered into the keyed // 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow -// (chrome, running sweep, leading expansion) and swaps in the interaction +// (chrome, running sweep, whole-row expand) and swaps in the interaction // outcome — `waiting` while pending, answered-count once settled, `cancelled` // when the user dismissed the whole set — because the questions themselves // render in the composer takeover. @@ -42,8 +42,9 @@ function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | nu /** Full row props: the toolview runtime share plus the standard locale seat. */ type AskQuestionRowProps = ToolRowProps & PropsLocale<'conversation'> -/** One-line question-interaction row (leading toggle expands the raw args). */ -export function AskQuestionRow({ toolName, block, t }: AskQuestionRowProps) { +/** One-line question-interaction row (the whole row toggles the call's + * Input/Output sections, ToolRow's unified expand). */ +export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowProps) { const model = toolRowModel(toolName, block) // Composer verdicts settle the call as specific UserInteractionErrors // (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own @@ -74,7 +75,9 @@ export function AskQuestionRow({ toolName, block, t }: AskQuestionRowProps) { title={t('ask.rowTitle')} summary={summary} body={model.body} + output={model.output} state={state} + inspect={inspect} /> ) } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index e9ee5286dc..fa607a0880 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,5 +1,5 @@ /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description), - plus the terminal card the row stacks under its summary line. */ + plus the expand-gated terminal card under the summary line. */ /* Summary line over the terminal card; the summary row keeps its own 24px height, so the card is a column around it rather than a change to it. */ @@ -8,10 +8,23 @@ flex-direction: column; } -/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), - and replaces the primitive's standalone vertical margin with the flow's. */ +/* Expanded terminal card, matching ToolRow's terminalBody: 4px indent, l1 + hairline, and the max-height scroll on the card's own OUTPUT (banner stays + pinned; 224px = the 260px card cap minus the ~36px banner); the margin + replaces the primitive's standalone vertical margin with the flow's. */ .terminal { - margin: 4px 0 4px 22px; + --dsl-terminal-font: var(--dsw-font-markdown-code-block-small); + --dsl-terminal-line-height: 18px; + --dsl-terminal-output-max-height: 224px; + margin: 4px 0 4px 4px; + border: 1px solid var(--dsw-alias-border-l1); +} + +/* ToolRow's unified expand interaction, replicated per the registrant + posture: pointer on the expandable row (the icon→chevron hover preview is + the affordance, no row fill). */ +.root[data-expandable] { + cursor: pointer; } .root { @@ -47,6 +60,7 @@ } .leading { + position: relative; /* .chevronHover overlay anchor */ flex: none; width: 16px; height: 16px; @@ -57,6 +71,34 @@ color: var(--dsw-alias-label-tertiary); } +.chevron { + color: var(--dsw-alias-label-secondary); +} + +/* Hover preview on the expandable row: the idle icon crossfades (100ms) into + a down chevron before the row is opened — same overlay as ToolRow. */ +.iconIdle { + display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; +} + +.chevronHover { + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; +} + +.root:hover .iconIdle { + opacity: 0; +} + +.root:hover .chevronHover { + opacity: 1; +} + .scopeBadge { flex: none; margin-right: 8px; @@ -95,6 +137,52 @@ color: var(--dsw-alias-label-tertiary); } +/* Error row's collapsed summary: the failure's first line in the error color. */ +.errorSummary { + color: var(--dsw-alias-state-error-primary); +} + +/* Hover-revealed Inspect pill under the expanded terminal's bottom-left — + ToolRow's .bodyWrap/.inspectButton treatment, replicated per the registrant + posture: real flow (it reserves its line), revealed by hovering anywhere on + the tool call — title row included — or by keyboard focus. */ +.bodyWrap { + display: flex; + flex-direction: column; +} + +.inspectButton { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 4px; + margin: 4px 0 2px 4px; + padding: 2px 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + /* Base background, not bg-overlay: the overlay token reads too heavy. */ + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + cursor: pointer; + opacity: 0; + transition: opacity 100ms ease; +} + +.card:hover .inspectButton, +.inspectButton:focus-visible { + opacity: 1; +} + +/* Solid hover fill: the pill floats over terminal output, so a translucent + hover token would let the text underneath bleed through. */ +.inspectButton:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); + color: var(--dsw-alias-label-primary); +} + + .visuallyHidden { position: absolute; width: 1px; diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index e964ef10ae..06eb42f741 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -4,20 +4,24 @@ // Child sessions keep a scoped badge so session-dimension differentiation stays // observable inside the component (no parallel registry). // -// A bash call declares the terminal render intent, so this row also renders -// the command's own output through TerminalBlock. This row has no expand -// control and is not a details-panel target either (tool rows stopped being -// one), so its terminal body is resident rather than expand-gated as in -// ToolRow, and the card's own copy and expand controls are the row's only -// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat -// flow's tighter cap over the block's own default of 16 — and the block's -// internal expander keeps a long output from taking over the message flow. +// A bash call declares the terminal render intent, so this row renders the +// command's own output through TerminalBlock — expand-gated exactly like +// ToolRow's unified interaction: collapsed by default, the whole summary row +// is the toggle (click / Enter / Space, icon→chevron hover preview; the +// summary stays inline while open), +// and the expanded card max-height-scrolls inside its own surface with the +// full output (maxLines Infinity — no middle collapse). An error row's +// collapsed summary is the failure's first line in the error color. +import { useState, type KeyboardEvent } from 'react' import type { Context } from 'cordis' -import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import clsx from 'clsx' +import { + IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolRowProps } from '../contract/slots.ts' -import { CHAT_TERMINAL_MAX_LINES, terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts' +import { terminalBlockLabels, terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts' import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import { NS } from '../locales.ts' import css from './bash-sample.module.css' @@ -45,43 +49,89 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { } /** - * Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the - * command's terminal card resident below it. The summary row is not a - * details-panel control (tool rows stopped being one), so the card's copy and - * expand controls are the row's only interactions. + * Bash row: icon + Bash · {description} in the shared ToolRow chrome, the + * whole row toggling the command's terminal card (ToolRow's unified + * expand interaction, replicated locally per the registrant posture). */ -export function BashRow({ toolName, block, sessionId, useSessions, t }: BashRowProps) { +export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) { const model = toolRowModel(toolName, block) // Session workspace root: the terminal view's cwd resolves against it (an // omitted workdir IS the workspace), which the pure presenter cannot do. const cwd = useSessions(list => list.byId[sessionId]?.cwd) const terminal = terminalCardModel(block, cwd) + // A failing exit status is the terminal card's own error signal (the call + // itself settles isError:false), surfaced as the row's red state dot. + const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal) + ? 'error' + : model.state const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) - const status = stateStatus(model.state, t) + const status = stateStatus(state, t) + const [expanded, setExpanded] = useState(false) + const expandable = terminal !== null + const open = expanded && expandable + const failureLine = model.state === 'error' ? model.errorSummary : null + const toggleExpand = () => { + setExpanded(v => !v) + } + const toggleFromKeyboard = (event: KeyboardEvent) => { + if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + toggleExpand() + } + const leading = open + ? + : expandable + ? ( + <> + {leadingFor(state)} + + + ) + : leadingFor(state) return (
- {leadingFor(model.state)} + {leading} {status !== null && {status}} {isChild && scoped} {model.title} {/* The terminal presenter's description is the contractual - above-card summary; it outranks the args-derived one. */} - {terminal?.description ?? model.summary} + above-card summary; a failure's first line outranks both. */} + + {failureLine ?? terminal?.description ?? model.summary} +
- {terminal !== null && ( - + {terminal !== null && open && ( + /* Same hover-Inspect posture as ToolRow's expanded body, replicated + locally per the registrant posture. */ +
+ + {inspect !== undefined && ( + + )} +
)}
) diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css new file mode 100644 index 0000000000..3ecf480adf --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css @@ -0,0 +1,130 @@ +/* File-mutation toolview: same geometry/tokens as ToolRow (figma + {Edit,Write} · path), plus the diff card the row stacks under its summary + line. Mirrors bash-sample.module.css, whose terminal card this replaces with + a diff card. */ + +/* Summary line over the diff card; the summary row keeps its own 24px height, + so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.diff { + margin: 4px 0 4px 22px; +} + +.root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-file-mutation-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-file-mutation-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +.leading { + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +/* File-tool path: same geometry as .summary; hover underline + pointer. */ +.fileLink { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + text-align: left; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.fileLink:hover { + text-decoration: underline; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* The result text for an errored mutation, indented to the card's own column + (the diff card's inset) and in the error tone, since it stands in for the diff + card the failure path does not produce. */ +.failure { + margin: 4px 0 4px 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx new file mode 100644 index 0000000000..323a73e77c --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx @@ -0,0 +1,123 @@ +// File-mutation toolview registrant: third-party posture over the keyed +// toolview hole (ctx.slots.register + ToolRowProps only — never imports the +// chat domain), registered under both `edit` and `write`. Product chrome +// matches ToolRow (figma: {Edit,Write} · {path}). +// +// A write/edit call declares the diff render intent, so this row renders the +// applied change through DiffBlock resident below its summary line — the same +// posture BashRow gives a terminal card. The row has no expand control and is +// not a details-panel target (tool rows stopped being one), so the diff body +// is resident rather than expand-gated, and the card's own copy and expand +// controls are the row's only interactions. CHAT_DIFF_MAX_LINES caps the body +// against the message flow; the details panel keeps the block's full default. +// The summary stays a path link (the file-tool interaction) that opens through +// the host. + +import type { Context } from 'cordis' +import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './file-mutation-row.module.css' + +function leadingFor(state: ToolRowState) { + switch (state) { + case 'error': return + case 'stopped': return + // Running keeps the icon — the row sweep carries the in-flight signal. + default: return + } +} + +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * A settled result's text, flattened from its content blocks, for the arm that + * shows a failure the diff card cannot: write/edit return `undefined` from + * `presentResult` on `result.isError`, so an errored mutation has no diff card, + * and the keyed row is not a details-panel target. Without this the failure — + * an `old_string` that did not match, a permission denial — would read as a bare + * red dot with the model-facing error text nowhere on screen. + * @param block - the frozen call slice. + * @returns the result text, or null for a running call or an empty result. + */ +function errorText(block: ToolRowProps['block']): string | null { + if (!('kind' in block)) return null + const parts: string[] = [] + for (const item of block.content) { + if (item.type === 'text') parts.push(item.text) + } + if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) + const text = parts.join('\n') + return text === '' ? null : text +} + +/** + * File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome, + * with the applied diff resident below it. The summary is a path link (a file + * tool's interaction); the host's `openFile` resolves it against the session + * cwd, so this passes the tool's own path verbatim. The card's copy and expand + * controls are the row's only other actions. + */ +export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) { + const model = toolRowModel(toolName, block, cwd) + const diff = diffCardModel(block) + const status = stateStatus(model.state) + const filePath = model.filePath + // An errored mutation has no diff card (presentResult returns undefined on + // isError); surface its result text so the failure is more than a red dot. + const failure = diff === null && model.state === 'error' ? errorText(block) : null + return ( +
+
+ {leadingFor(model.state)} + {status !== null && {status}} + {model.title} + + {filePath !== undefined ? ( + + ) : ( + {model.summary} + )} +
+ {diff !== null && ( + + )} + {failure !== null &&
{failure}
} +
+ ) +} + +/** + * The file-mutation rows as a plain registrant plugin. `inject` carries the + * load-order seam: requiring the conversation service guarantees the chat entry + * (and with it the 'conversation.chat.toolview' declaration) is registered — + * ui-conversation's apply mounts the service after the chat entry. + */ +export const fileMutationToolview = { + name: 'file-mutation-toolview', + inject: ['slots', 'conversation'], + /** + * Register the file-mutation row into the chat view's keyed toolview hole + * under both mutation tool names. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit' }, FileMutationRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write' }, FileMutationRow) + }, +} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index 17fa7fb50a..e6abaa171f 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -1,10 +1,10 @@ // todo_write toolview: plan-flavored summary row replacing the generic // "Tool call" card, registered into the keyed 'conversation.chat.toolview' // hole like the bash sample (a product registration, not a sample). The row -// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a +// composes ToolRow (chrome, running sweep, whole-row expand) and swaps in a // summary of the written list (counts + active item) from the call args; the // durable list itself renders in the TodoPanel above the composer, so the -// row stays one line. +// row stays one line until expanded. import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from 'cordis' @@ -45,10 +45,11 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): string | null { : head } -/** One-line plan update row (leading toggle expands the raw args). Non-ok - * execution states keep the shared row's dot semantics — a cancelled call - * wrote no todo/write, so it must not read as a completed update. */ -export function TodoRow({ toolName, block, t }: TodoRowProps) { +/** One-line plan update row (the whole row toggles the call's Input/Output + * sections, ToolRow's unified expand). Non-ok execution states keep the + * shared row's dot semantics — a cancelled call wrote no todo/write, so it + * must not read as a completed update. */ +export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) { const model = toolRowModel(toolName, block) const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' const summary = summarize(argsRaw, t) ?? model.summary @@ -61,7 +62,10 @@ export function TodoRow({ toolName, block, t }: TodoRowProps) { title={t('todo.rowTitle')} summary={summary} body={model.body} + output={model.output} + errorSummary={model.errorSummary} state={model.state} + inspect={inspect} /> ) } diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.module.css b/packages/client/ui-conversation/src/client/toolviews/web-row.module.css new file mode 100644 index 0000000000..0b1519e218 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.module.css @@ -0,0 +1,95 @@ +/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus + the web card the row stacks under its summary line, mirroring the bash row's + resident terminal card. */ + +/* Summary line over the web card; the summary row keeps its own 24px height, + so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.web { + margin: 4px 0 4px 22px; +} + +.root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-web-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-web-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +.leading { + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx new file mode 100644 index 0000000000..b86c523a26 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx @@ -0,0 +1,92 @@ +// Web toolview registrant: third-party posture over the keyed toolview hole +// (ctx.slots.register + ToolRowProps only — never imports the chat domain). +// Registered under BOTH web_search and web_fetch, since both declare the one +// `web` render intent and render through the one WebBlock family; the row +// discriminates on the toolName only to pick its icon and title. +// +// A web tool declares the `web` render intent at result time, so this row +// renders the completed retrieval through WebBlock resident below its summary, +// the same posture BashRow uses for the terminal card: no expand control on the +// row itself, not a details-panel target, and the block's own expander keeps a +// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is +// passed as maxSources — the chat flow's tighter cap over the block's default +// of 16). Until the call settles there is no web card (the tools keep a generic +// pending view), so a running row is the summary line alone. + +import type { Context } from 'cordis' +import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './web-row.module.css' + +/** web_fetch reads one URL; web_search queries. Titles are figma literals. */ +const WEB_TITLES: Record = { + web_search: 'Search', + web_fetch: 'Fetch', +} + +/** Leading icon per tool, yielding to the state semantic while failed/stopped. */ +function leadingFor(toolName: string, state: ToolRowState) { + switch (state) { + case 'error': return + case 'stopped': return + // Running keeps the icon — the row sweep carries the in-flight signal. + default: return toolName === 'web_fetch' ? : + } +} + +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with + * the completed retrieval's web card resident below it. The summary row is not + * a details-panel control (tool rows stopped being one), so the card's own + * links and expander are the row's only interactions. + */ +export function WebRow({ toolName, block }: ToolRowProps) { + const model = toolRowModel(toolName, block) + const web = webCardModel(block) + const status = stateStatus(model.state) + return ( +
+
+ {leadingFor(toolName, model.state)} + {status !== null && {status}} + {WEB_TITLES[toolName] ?? model.title} + + {model.summary} +
+ {web !== null && ( + + )} +
+ ) +} + +/** + * The web rows as a plain registrant plugin, riding the same load-order seam as + * the bash sample: `inject: ['conversation']` guarantees the chat entry (and + * with it the 'conversation.chat.toolview' declaration) is on the ledger. One + * WebRow component registers under both web tool names. + */ +export const webToolview = { + name: 'web-toolview', + inject: ['slots', 'conversation'], + /** + * Register the web row under both web tool names' keyed toolview holes. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow) + }, +} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 367b3519e9..9806850db2 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -188,9 +188,11 @@ describe('conversation slot inject surface', () => { // hooks compartment still present so the render side's hook order holds. const absent = injectFn(undefined) expect(absent.keyboard).toBeUndefined() + expect(absent.toggleCommandMenu).toBeUndefined() expect(absent.stop).toBeUndefined() expect(absent.hooks.notices.getSnapshot()).toBeNull() expect(absent.hooks.lexicon.getSnapshot().size).toBe(0) + expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull() // A scope whose service tree lost 'conversation' (the feature fiber // unloaded while a retained inject closure re-runs): fails loud too. const stop = injectFn(ROOT).stop! diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 87b663dfc7..a6c48e5ab8 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -136,7 +136,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => { }) describe('terminal card assembly', () => { - it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => { + it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => { const runtime = await bench([ bashResult(3, 'c-keyed'), // An unregistered tool with terminal views: GenericToolCard fallback. @@ -144,15 +144,20 @@ describe('terminal card assembly', () => { ]) const view = runtime.renderRoot() - // Keyed BashRow renders the card residently (no expand gesture). - const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement - expect(keyed?.querySelector('[data-terminal]')).not.toBeNull() + // Keyed BashRow: collapsed by default, the whole summary row is the toggle. + const keyedRow = view.container.querySelector('[data-sample="bash-global"]') + const keyed = keyedRow?.parentElement + expect(keyed?.querySelector('[data-terminal]')).toBeNull() + fireEvent.click(keyedRow!) + await waitFor(() => { + expect(keyed!.querySelector('[data-terminal]')).not.toBeNull() + }) - // Fallback row: card appears only after its expand control. + // Fallback row: same unified expand interaction. const fallback = view.container.querySelector('[data-tool="fx-bash"]') expect(fallback).not.toBeNull() expect(fallback!.querySelector('[data-terminal]')).toBeNull() - fireEvent.click(fallback!.querySelector('button[aria-expanded]')!) + fireEvent.click(fallback!.querySelector('[data-expandable]')!) await waitFor(() => { expect(fallback!.querySelector('[data-terminal]')).not.toBeNull() }) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 3ba318ff74..239f690998 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -84,12 +84,14 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample and the product rows as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => { const b = await bench() // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the - // service being present implies the chat entry declared the hole first. + // service being present implies the chat entry declared the hole first. The + // file-mutation registrant claims both write and edit for the diff card; the + // web rows register one component under both web tool names. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write', 'ask_user_question']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 934045426c..ef3f7400b6 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -18,7 +18,10 @@ import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { zh } from '../src/client/locales.ts' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) // Mirrors the real lookup chain (conversation namespace, then common). const t: MessageItemProps['t'] = makeTranslate(zh, commonZh) @@ -188,6 +191,157 @@ describe('MessageItem arms', () => { fireEvent.click(row) // a disabled control stays collapsed expect(row.getAttribute('aria-expanded')).toBeNull() }) + + it('collapses retry details behind the durable model retry status', () => { + vi.useFakeTimers() + vi.setSystemTime(10_000) + const view = render( + , + ) + const details = view.container.querySelector('details') + const summary = view.container.querySelector('summary') + expect(details?.open).toBe(false) + expect(details?.dataset.active).toBe('true') + expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 3s') + expect(view.getByText('重试延迟:').parentElement?.textContent).toBe('重试延迟:2500ms') + expect(view.getByText('失败原因:').parentElement?.textContent).toBe('失败原因:连接被重置') + + act(() => { vi.advanceTimersByTime(1_100) }) + expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 2s') + act(() => { vi.advanceTimersByTime(1_000) }) + expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') + + view.rerender( + , + ) + expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 4s') + + if (summary === null) throw new Error('retry summary missing') + fireEvent.click(summary) + expect(details?.open).toBe(true) + + view.rerender( + , + ) + expect(details?.dataset.active).toBeUndefined() + expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 4s') + + view.rerender( + , + ) + expect(view.getByRole('status').textContent).toBe('已重试模型请求(3/∞) · 4s') + + view.rerender( + , + ) + expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s') + }) + + it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => { + vi.useFakeTimers() + vi.setSystemTime(10_000) + const node = { + kind: 'model-retry', + seq: 5, + time: 10_000, + retryState: 'scheduled', + turn: 1, + step: 0, + provider: 'mock', + mode: 'normal', + policyKey: 'mock-normal', + retry: 1, + maxRetries: 2, + delayMs: 5_000, + failure: { code: 'TRANSPORT', message: '连接被重置' }, + } as const + const view = render() + expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s') + + act(() => { vi.advanceTimersByTime(4_200) }) + view.rerender() + expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') + }) }) describe('formatMessageClock', () => { diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 54b41c9acf..e0d44386e9 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -205,7 +205,7 @@ describe('run_code sub-calls through the real chat machinery', () => { expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent) .toContain('Unmount temporary Plugindyn-2') - fireEvent.click(mounted!.querySelector('button[aria-expanded]')!) + fireEvent.click(mounted!.querySelector('[data-expandable]')!) expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code) }) @@ -213,8 +213,8 @@ describe('run_code sub-calls through the real chat machinery', () => { const parent = 'call-64' const b = await bench(snapshotWith([codeResult(10, parent)], new Map())) const view = mountApp(b.slots) - // The code row is expandable via its leading control (body = the program). - const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]') + // The code row is expandable via the whole summary row (body = the program). + const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]') expect(toggle).not.toBeNull() fireEvent.click(toggle!) // Shiki splits the program into token spans inside one
:
diff --git a/packages/client/ui-conversation/tests/chat-store.spec.ts b/packages/client/ui-conversation/tests/chat-store.spec.ts
index 50ec5542ef..17d90cfbde 100644
--- a/packages/client/ui-conversation/tests/chat-store.spec.ts
+++ b/packages/client/ui-conversation/tests/chat-store.spec.ts
@@ -12,7 +12,7 @@ beforeEach(() => {
 describe('createChatStore', () => {
   it('init shape: empty selection/draft/view', () => {
     const store = createChatStore().create()
-    expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
+    expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null })
   })
 
   it('actions cover the declared write set', () => {
@@ -30,6 +30,11 @@ describe('createChatStore', () => {
 
     store.actions.setView('chat')
     expect(store.store.getSnapshot().view).toBe('chat')
+
+    store.actions.setInspect({ callId: 'c1' })
+    expect(store.store.getSnapshot().inspect).toEqual({ callId: 'c1' })
+    store.actions.setInspect(null)
+    expect(store.store.getSnapshot().inspect).toBeNull()
   })
 
   it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
index 913741a4dc..e0d01656ba 100644
--- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
@@ -6,7 +6,7 @@ afterEach(cleanup)
 import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
+import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/contract/tool-call-model.ts'
 import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
 import { ToolRow } from '../src/client/chat/ToolRow.tsx'
 import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
@@ -107,6 +107,29 @@ describe('tool-call-model', () => {
       .toBe('{\n  "code": ""\n}')
   })
 
+  it('resultText flattens text blocks verbatim, other shapes as JSON, empty error content to name: code', () => {
+    expect(resultText(result({ content: [{ type: 'text', text: 'a\nb' }] }))).toBe('a\nb')
+    expect(resultText(result({ content: [{ type: 'text', text: 'a' }, { type: 'image', data: 'x' } as never] })))
+      .toBe(`a\n${JSON.stringify({ type: 'image', data: 'x' }, null, 2)}`)
+    expect(resultText(result({ content: [], isError: true, error: { name: 'ToolError', code: 'denied' } })))
+      .toBe('ToolError: denied')
+    expect(resultText(result({ content: [] }))).toBe('')
+  })
+
+  it('derives output from the settled result and null while running or blank', () => {
+    expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'out' }] })).output).toBe('out')
+    expect(toolRowModel('bash', running()).output).toBeNull()
+    expect(toolRowModel('bash', result({ content: [] })).output).toBeNull()
+  })
+
+  it('derives errorSummary as the first output line on error rows only', () => {
+    const failed = result({ content: [{ type: 'text', text: 'boom\ndetail' }], isError: true })
+    expect(toolRowModel('bash', failed).errorSummary).toBe('boom')
+    expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'boom' }] })).errorSummary).toBeNull()
+    expect(toolRowModel('bash', result({ content: [], isError: true })).errorSummary).toBeNull()
+    expect(toolRowModel('bash', running()).errorSummary).toBeNull()
+  })
+
   it('gives Cordis lifecycle tools action titles over their generic variants', () => {
     expect(toolRowModel('cordis_inspect', running({
       name: 'cordis_inspect',
@@ -150,14 +173,15 @@ describe('ToolRow', () => {
     expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
   })
 
-  it('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
+  it('row click expands: chevron leading, summary kept inline, body in the scrolling card', () => {
     const view = render()
-    fireEvent.click(view.container.querySelector('button')!)
+    fireEvent.click(view.getByRole('button'))
     expect(view.queryByTestId('tool-icon')).toBeNull()
     expect(view.container.querySelector('svg')).not.toBeNull()
-    expect(view.queryByText('List files')).toBeNull()
+    expect(view.getByText('List files')).toBeTruthy()
     expect(view.getByText(/"a": 1/)).toBeTruthy()
-    fireEvent.click(view.container.querySelector('button')!)
+    expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
+    fireEvent.click(view.getByRole('button'))
     expect(view.queryByTestId('tool-icon')).not.toBeNull()
     expect(view.getByText('List files')).toBeTruthy()
   })
@@ -168,16 +192,20 @@ describe('ToolRow', () => {
     expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
     const errorView = render()
     expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
+    // The dot rides the idle slot, so an expandable error row keeps the
+    // icon→chevron hover preview instead of losing it with the icon.
+    expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull()
   })
 
-  it('non-expandable rows render a passive leading slot', () => {
+  it('non-expandable rows render a passive leading slot and no row button', () => {
     const view = render()
-    expect(view.container.querySelector('button')).toBeNull()
+    expect(view.queryByRole('button')).toBeNull()
+    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
     expect(view.queryByTestId('tool-icon')).not.toBeNull()
   })
 
-  it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
-    const view = render()
+  it('the row toggles from Enter and Space, ignoring other keys', () => {
+    const view = render()
     const row = view.getByRole('button')
     fireEvent.keyDown(row, { key: 'Tab' })
     expect(row.getAttribute('aria-expanded')).toBe('false')
@@ -187,32 +215,31 @@ describe('ToolRow', () => {
     expect(row.getAttribute('aria-expanded')).toBe('false')
   })
 
-  it('a non-expandable expandOnRowClick row exposes no row button', () => {
-    const view = render()
-    expect(view.queryByRole('button')).toBeNull()
-  })
-
-  it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
+  it('file rows expand from the row while the path link opens without toggling', () => {
     const open = vi.fn()
     const view = render(
       ,
     )
+    const row = view.getByRole('button', { name: /Read/ })
+    // Path click opens the file and leaves the row collapsed.
     fireEvent.click(view.getByText('src/a.ts'))
     expect(open).toHaveBeenCalledWith('src/a.ts')
-    // Only the path link is a button — no args-expand affordance on file rows.
-    expect(view.container.querySelectorAll('button')).toHaveLength(1)
-    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
-    expect(view.queryByText(/"a": 1/)).toBeNull()
+    expect(row.getAttribute('aria-expanded')).toBe('false')
+    // Row click (outside the link) expands the args body.
+    fireEvent.click(row)
+    expect(row.getAttribute('aria-expanded')).toBe('true')
+    expect(view.getByText(/"a": 1/)).toBeTruthy()
   })
 
-  it('a single-file path disables expand even when onOpenFile is absent', () => {
+  it('a file path without onOpenFile renders a plain summary on an expandable row', () => {
     const view = render(
       ,
     )
     expect(view.container.querySelector('button')).toBeNull()
-    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
-    fireEvent.click(view.getByText('作文.md'))
-    expect(view.queryByText(/"a": 1/)).toBeNull()
+    const row = view.getByRole('button')
+    fireEvent.click(row)
+    expect(row.getAttribute('aria-expanded')).toBe('true')
+    expect(view.getByText(/"a": 1/)).toBeTruthy()
   })
 
   it('non-file rows do not open anything when the summary is clicked', () => {
@@ -221,6 +248,75 @@ describe('ToolRow', () => {
     fireEvent.click(view.getByText('List files'))
     expect(open).not.toHaveBeenCalled()
   })
+
+  it('an error row shows the failure first line in the collapsed summary and the full text expanded', () => {
+    const view = render(
+      ,
+    )
+    expect(view.getByText('boom')).toBeTruthy()
+    expect(view.queryByText('List files')).toBeNull()
+    fireEvent.click(view.getByRole('button'))
+    expect(view.getByText(/detail/)).toBeTruthy()
+    expect(view.container.querySelector('[data-error]')).not.toBeNull()
+  })
+
+  it('an error row without an error summary keeps the args summary', () => {
+    const view = render()
+    expect(view.getByText('List files')).toBeTruthy()
+  })
+
+  it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
+    const open = vi.fn()
+    const view = render(
+      ,
+    )
+    fireEvent.click(view.getByText('cannot overwrite'))
+    expect(open).not.toHaveBeenCalled()
+    // The failure line renders as plain text, not the underlined link button.
+    expect(view.container.querySelector('[class*="fileLink"]')).toBeNull()
+  })
+
+  it('the expanded body carries a hover Inspect pill that fires the callback', () => {
+    const inspect = vi.fn()
+    const view = render()
+    // Collapsed: no pill.
+    expect(view.queryByText('Inspect')).toBeNull()
+    fireEvent.click(view.getByRole('button', { name: /Bash/ }))
+    const pill = view.getByText('Inspect')
+    fireEvent.click(pill)
+    expect(inspect).toHaveBeenCalledTimes(1)
+    // The pill click must not collapse the row (body is a .row sibling).
+    expect(view.getByRole('button', { name: /Bash/ }).getAttribute('aria-expanded')).toBe('true')
+  })
+
+  it('no inspect callback, no pill', () => {
+    const view = render()
+    fireEvent.click(view.getByRole('button'))
+    expect(view.queryByText('Inspect')).toBeNull()
+  })
+
+  it('the expanded card gutter-labels each section it carries (IN / OUT)', () => {
+    const both = render()
+    fireEvent.click(both.getByRole('button'))
+    expect(both.getByText('IN')).toBeTruthy()
+    expect(both.getByText('OUT')).toBeTruthy()
+    expect(both.getByText('result text')).toBeTruthy()
+    cleanup()
+    const inputOnly = render()
+    fireEvent.click(inputOnly.getByRole('button'))
+    expect(inputOnly.getByText('IN')).toBeTruthy()
+    expect(inputOnly.queryByText('OUT')).toBeNull()
+    cleanup()
+    const outputOnly = render()
+    fireEvent.click(outputOnly.getByRole('button'))
+    expect(outputOnly.queryByText('IN')).toBeNull()
+    expect(outputOnly.getByText('OUT')).toBeTruthy()
+    expect(outputOnly.getByText('only out')).toBeTruthy()
+  })
 })
 
 describe('ThinkRow', () => {
@@ -241,6 +337,22 @@ describe('ThinkRow', () => {
     fireEvent.click(view.getByText('Think'))
     expect(row.getAttribute('aria-expanded')).toBe('false')
   })
+
+  it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
+    const view = render(
+      ,
+    )
+    fireEvent.click(view.getByText('Think'))
+    // The summary (first line) is gone from the row; only the body carries it.
+    expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
+    expect(view.queryByText('IN')).toBeNull()
+    expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
+    expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
+  })
 })
 
 describe('GenericToolCard', () => {
@@ -290,6 +402,14 @@ describe('GenericToolCard', () => {
     expect(view.container.querySelector('svg')).not.toBeNull()
   })
 
+  it('passes the owner inspect callback through to the expanded row pill', () => {
+    const inspect = vi.fn()
+    const view = render()
+    fireEvent.click(view.getByRole('button', { name: /Bash/ }))
+    fireEvent.click(view.getByText('Inspect'))
+    expect(inspect).toHaveBeenCalledTimes(1)
+  })
+
   it('file-path summary click reaches openFile; bash summary does not', () => {
     const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
     const fileView = render()
diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
index 5268be08f6..eb48677d4f 100644
--- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
@@ -114,7 +114,7 @@ describe('keyed toolview hole through the real machinery', () => {
     expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
       .toContain('Unmount temporary Plugindyn-2')
 
-    fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
+    fireEvent.click(mounted!.querySelector('[data-expandable]')!)
     expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
     await b.runtime.dispose()
   })
diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
index c7931eaa33..ec0e15a2bc 100644
--- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx
@@ -7,8 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { Profiler } from 'react'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import type {
-  AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId,
-  SessionListState, ToolResultNode, UserMessageNode, WorkspaceListState,
+  AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
+  ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode,
+  UserMessageNode, WorkspaceListState,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
@@ -67,6 +68,13 @@ const user = (seq: number, text: string): UserMessageNode => ({
 const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
   kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
 })
+const retry = (seq: number): ModelRetryNode => ({
+  kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0,
+  retryState: 'scheduled',
+  provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
+  retry: 1, maxRetries: 2, delayMs: 450,
+  failure: { code: 'TRANSPORT', message: '连接被重置' },
+})
 const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
   kind: 'tool-result', seq, time: seq * 1_000, callId,
   call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
@@ -97,6 +105,13 @@ function makeHarness(init?: Partial) {
   const openDetails = vi.fn<(t: SelectionTarget) => void>()
   const openFile = vi.fn<(path: string) => void>()
   const loadOlder = vi.fn()
+  const inspectCall = vi.fn<(callId: string) => void>()
+  // In-memory scroll memory matching the apply.ts per-session map contract.
+  let savedScrollTop: number | null = null
+  const chatScroll = {
+    save: (top: number | null) => { savedScrollTop = top },
+    read: () => savedScrollTop,
+  }
   const forkAt = vi.fn()
   // Selection rides the REAL chat store (same construction path as
   // production; the view reads it through the PropsStore useStore share).
@@ -124,12 +139,14 @@ function makeHarness(init?: Partial) {
     openDetails,
     openFile,
     loadOlder,
+    inspectCall,
+    chatScroll,
     forkAt,
     // Mirrors the real lookup chain (conversation namespace, then common).
     t: makeTranslate(zh, commonZh),
   }
   const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
-  return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
+  return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
 }
 
 describe('chat-flow derivation', () => {
@@ -146,6 +163,17 @@ describe('chat-flow derivation', () => {
     expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
   })
 
+  it('reuses one stable row for consecutive retry turns', () => {
+    const first = retry(2)
+    const second = { ...retry(3), turn: 2, retry: 2 }
+    const initial = deriveChatFlow([user(1, 'try'), first])
+    const updated = deriveChatFlow([user(1, 'try'), first, second])
+    expect(flowKeys(initial)).toBe('n1|n2')
+    expect(flowKeys(updated)).toBe('n1|n2')
+    expect(updated).toHaveLength(2)
+    expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
+  })
+
   it('skips render-nothing assistant nodes so tool runs stay one group', () => {
     // A tool-call-only step message (and blank text/reasoning) renders nothing:
     // it must not split the run into two groups with an empty line between.
@@ -218,6 +246,57 @@ describe('ChatView', () => {
     expect(view.getByText('run a')).toBeTruthy()
   })
 
+  it('animates only the latest unresolved model retry', () => {
+    const retryNode = retry(2)
+    const nextRetry = { ...retry(3), turn: 2, retry: 2 }
+    const context = {
+      kind: 'context', seq: 4, time: 4_000, content: [], source: null,
+    } as const satisfies ConversationNode
+    const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
+    const view = render()
+    const disclosure = view.container.querySelector('details')
+    expect(disclosure?.dataset.active).toBe('true')
+    expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
+
+    act(() => {
+      h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
+    })
+    expect(view.getAllByRole('status')).toHaveLength(1)
+    expect(view.container.querySelector('details')).toBe(disclosure)
+    expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s')
+
+    act(() => {
+      h.set({
+        nodes: [
+          user(1, 'try'),
+          retryNode,
+          { ...nextRetry, retryState: 'started' },
+          context,
+          assistant(5, 'done'),
+        ],
+        running: false,
+      })
+    })
+    expect(disclosure?.dataset.active).toBeUndefined()
+    expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s')
+
+    act(() => {
+      h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true })
+    })
+    expect(disclosure?.dataset.active).toBeUndefined()
+    expect(view.getByRole('status').textContent).toContain('重试已取消')
+  })
+
+  it('the expanded row Inspect pill hands the call id to inspectCall', () => {
+    const h = makeHarness({
+      nodes: [toolResult(3, 'a')],
+    })
+    const view = render()
+    fireEvent.click(view.getByRole('button', { name: /Bash/ }))
+    fireEvent.click(view.getByText('Inspect'))
+    expect(h.inspectCall).toHaveBeenCalledWith('a')
+  })
+
   it('shows assistant IconActions only on the last content message of each turn', () => {
     const h = makeHarness({
       nodes: [
@@ -331,11 +410,11 @@ describe('ChatView', () => {
     expect(rowRenders).toBe(afterMount)
   })
 
-  it('tool row expands to the args body via the leading slot toggle', () => {
+  it('tool row expands to the args body via the whole-row toggle', () => {
     const h = makeHarness({ nodes: [toolResult(3, 'a')] })
     const view = render()
     expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
-    fireEvent.click(view.container.querySelector('button[aria-expanded]')!)
+    fireEvent.click(view.container.querySelector('[data-expandable]')!)
     expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
   })
 
@@ -458,6 +537,55 @@ describe('ChatView', () => {
     }
   })
 
+  it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
+    const host = document.createElement('div')
+    host.setAttribute('data-conversation-scroll', '')
+    Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
+    Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
+    Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
+    document.body.appendChild(host)
+    try {
+      const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+      // Fresh open (nothing saved): the bottom jump stands.
+      const view = render(, { container: host })
+      expect(host.scrollTop).toBe(2000)
+      // Reader scrolls up; the position is recorded continuously.
+      host.scrollTop = 100
+      fireEvent.scroll(host)
+      // View-tab switch away and back: the view unmounts, then remounts.
+      view.rerender(
) + host.scrollTop = 0 + view.rerender() + expect(host.scrollTop).toBe(100) + // The restored position is above the floor: follow stays disarmed. + expect(view.getByLabelText('回到底部')).toBeTruthy() + } finally { + host.remove() + } + }) + + it('a remount while pinned to the bottom keeps the bottom jump', () => { + const host = document.createElement('div') + host.setAttribute('data-conversation-scroll', '') + Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true }) + Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true }) + Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true }) + document.body.appendChild(host) + try { + const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) + const view = render(, { container: host }) + // At the bottom: the scroll event records the pinned state (null). + fireEvent.scroll(host) + expect(h.chatScroll.read()).toBeNull() + view.rerender(
) + host.scrollTop = 0 + view.rerender() + expect(host.scrollTop).toBe(2000) + } finally { + host.remove() + } + }) + it('paging button loads older and shows its busy label', () => { const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true }) const view = render() diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx new file mode 100644 index 0000000000..409bd0e93d --- /dev/null +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -0,0 +1,344 @@ +// @vitest-environment jsdom +// The diff render intent on the web side: the pure diffCardModel derivation +// over callView/resultView, and both conversation render sites that consume it +// — the chat tool row's expanded body (GenericToolCard / FileMutationRow) and +// the details panel's Output section. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { FileMutationRow, fileMutationToolview } from '../src/client/toolviews/file-mutation-row.tsx' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const SID = 's1' as SessionId + +const t = makeTranslate(zh, commonZh) + +const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}' + +/** The edit tool's own call view (a call-time diff derived from the arguments). */ +const callDiff = (over?: Partial>): ToolCallView => ({ + card: 'diff', title: 'Edit notes/demo.txt', + diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over, +}) + +/** The edit tool's own result view (the applied hunk diff). */ +const resultDiff = (over?: Partial>): ToolResultView => ({ + card: 'diff', title: 'Edit notes/demo.txt', + diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over, +}) + +const running = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'edit', argsRaw: ARGS, + turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over, +}) + +const settled = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'edit', argsRaw: ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false, + callView: callDiff(), resultView: resultDiff(), ...over, +}) + +describe('diffCardModel', () => { + it('derives a running card from the call view alone', () => { + expect(diffCardModel(running())).toEqual({ + card: { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] }, + }) + }) + + it('derives a settled card from the result view, which replaces the call-time diff', () => { + // The applied hunks (result) win over the args-derived call diff. + expect(diffCardModel(settled({ + resultView: resultDiff({ diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }), + }))).toEqual({ + card: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }, + }) + }) + + it('renders a settled diff even when the window dropped the call head', () => { + // A truncated call carries only the result view, which holds the whole change. + expect(diffCardModel(settled({ call: null, callView: null }))?.card.diffs).toHaveLength(1) + }) + + it('returns null for every non-diff call: no views, generic views, unknown cards', () => { + expect(diffCardModel(running({ callView: null }))).toBeNull() + expect(diffCardModel(settled({ callView: null, resultView: null }))).toBeNull() + expect(diffCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull() + // A generic result settles a diff call on the generic path (write/edit's + // own execution-error arm). + expect(diffCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView + expect(diffCardModel(running({ callView: future }))).toBeNull() + expect(diffCardModel(settled({ + callView: future, resultView: { card: 'chart' } as unknown as ToolResultView, + }))).toBeNull() + }) + + it('falls back to null for a malformed diff payload off the wire', () => { + // toolEventViewSchema validates only the `card` string, so a version + // mismatch can deliver a diff card with an unusable diffs field. Each shape + // routes to the generic path instead of throwing inside DiffBlock. + const bad = (diffs: unknown): ToolResultView => ({ card: 'diff', diffs } as unknown as ToolResultView) + expect(diffCardModel(settled({ resultView: bad(undefined) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad('nope') }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([null]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 1, oldText: null, newText: 'x' }]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: 5, newText: 'x' }]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: null, newText: 9 }]) }))).toBeNull() + // The running side narrows identically. + expect(diffCardModel(running({ callView: { card: 'diff', diffs: 'nope' } as unknown as ToolCallView }))).toBeNull() + }) +}) + +describe('chat row diff body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ + callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), t, + }) + + it('the expanded body is the applied diff, capped tighter than the panel', () => { + expect(CHAT_DIFF_MAX_LINES).toBeLessThan(16) + const view = render() + // Collapsed: the summary row (path) only, no diff body. + expect(view.queryByText('hello fixture')).toBeNull() + // The path link is not the expand control; the leading toggle is. + fireEvent.click(view.container.querySelector('[data-expandable]')!) + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.getByText('hello fixture')).toBeTruthy() + }) + + it('a running diff call expands to its intended change', () => { + const view = render() + fireEvent.click(view.container.querySelector('[data-expandable]')!) + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + }) + + it('a non-diff call keeps the args-JSON text body', () => { + // A non-file tool name so the row is not single-file (no path link), and its + // args body is the fallback the diff card must not have replaced. + const view = render() + fireEvent.click(view.container.querySelector('[data-expandable]')!) + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.getByText(/"foo"/)).toBeTruthy() + }) +}) + +describe('FileMutationRow diff card', () => { + const list = () => createSnapshotStore({ + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + current: SID, + phase: 'ready', + }) + + const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): ToolRowProps => ({ + callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app', + sessionId: SID, useSessions: bindSnapshotSelector(list()), + } as unknown as ToolRowProps) + + it('renders the applied diff under the summary row, without an expand gesture', () => { + const view = render() + // The diff card is resident (no expand toggle needed). + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.getByText('hello fixture')).toBeTruthy() + expect(view.getByText('复制')).toBeTruthy() + }) + + it('the summary is a path link that opens the tool path through the host', () => { + const openFile = vi.fn() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' })) + // The row passes the tool's own path; the injected openFile resolves it + // against the session cwd (apply.ts), so the row must not resolve twice. + expect(openFile).toHaveBeenCalledWith('notes/demo.txt') + }) + + it('registers under write too, rendering a create as an added-only diff', () => { + const writeArgs = '{"file_path":"notes/new.txt","content":"hello fixture\\n"}' + const view = render() + expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy() + }) + + it('reflects the run state on its leading slot', () => { + const runningView = render() + expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() + cleanup() + const errorView = render() + expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() + }) + + it('a mutation call with no diff view renders the summary row alone', () => { + const view = render() + expect(view.container.querySelector('[data-diff]')).toBeNull() + }) + + it('surfaces the result text when an errored mutation has no diff card', () => { + // write/edit return undefined from presentResult on isError, so the failure + // has no diff — the row shows the model-facing error text instead of a bare + // red dot. + const view = render() + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.getByText('old_string not found in notes/demo.txt')).toBeTruthy() + }) + + it('falls back to the error name/code when an errored result has no text block', () => { + const view = render() + expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy() + }) + + it('shows no failure text for a successful diff or a running call', () => { + const ok = render() + expect(ok.container.querySelector('[class*="_failure_"]')).toBeNull() + cleanup() + const run = render() + expect(run.container.querySelector('[class*="_failure_"]')).toBeNull() + }) + + it('shows the stopped state when the call was interrupted', () => { + const view = render() + expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() + // The visually-hidden status label carries the stopped semantic for AT. + expect(view.getByText('已停止')).toBeTruthy() + }) + + it('renders a plain summary span when the call carries no file path', () => { + // Empty args leave deriveFilePath undefined, so the summary is not a link. + const view = render() + expect(view.container.querySelector('[class*="_fileLink_"]')).toBeNull() + expect(view.container.querySelector('[class*="_summary_"]')).not.toBeNull() + }) +}) + +describe('fileMutationToolview registration', () => { + it('registers one component under both edit and write, and each disposes', () => { + const registered: { key: string; disposed: boolean }[] = [] + const disposers: (() => void)[] = [] + const ctx = { + slots: { + register: ({ key }: { name: string; key: string }) => { + const entry = { key, disposed: false } + registered.push(entry) + const dispose = () => { entry.disposed = true } + disposers.push(dispose) + return dispose + }, + }, + } + fileMutationToolview.apply(ctx as never) + expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write']) + // The registrant's inject seam is the load-order contract the row relies on. + expect(fileMutationToolview.inject).toEqual(['slots', 'conversation']) + // Disposal removes each contribution (packages/AGENTS.md registry contract). + for (const dispose of disposers) dispose() + expect(registered.every(r => r.disposed)).toBe(true) + }) +}) + +describe('DetailsPanel diff Output section', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore(cwd === undefined + ? { ids: [], byId: {}, current: undefined, phase: 'ready' } + : { + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + current: SID, + phase: 'ready', + }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + t={t} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'edit' } + + it('renders the applied diff at full height, keeping the JSON Input section', () => { + const view = mount(snapshot({ nodes: [settled()] }), target) + expect(view.getByText(/"file_path"/)).toBeTruthy() + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.getByText('hello fixture')).toBeTruthy() + }) + + it('a running diff call renders its intended change, not the 运行中… placeholder', () => { + const view = mount(snapshot({ runningCalls: [running()] }), target) + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.queryByText('运行中…')).toBeNull() + }) + + it('a non-diff result keeps the flattened pre', () => { + const view = mount(snapshot({ + nodes: [settled({ + callView: null, resultView: null, + content: [{ type: 'text', text: 'permission denied' }], + })], + }), target) + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('permission denied') + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 2ab9607ba7..6d6c09df0b 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -46,10 +46,13 @@ interface BenchOptions { variant?: 'hero' | 'composer' placeholder?: string t?: InputBarProps['t'] + command?: (line: string) => Promise accessory?: React.ReactNode overlay?: React.ReactNode leftItems?: React.ReactNode rightItems?: React.ReactNode + commandMenuOpen?: boolean + toggleCommandMenu?: (selection: { start: number; end: number }) => void } /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ @@ -77,6 +80,7 @@ function bench(over?: BenchOptions) { promptError: over?.promptError ?? null, })) const stop = vi.fn() + const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { slotCalls.push({ key, owner }) @@ -100,10 +104,12 @@ function bench(over?: BenchOptions) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(menuLauncher), stop, - command: () => Promise.resolve(true), + command: over?.command ?? (() => Promise.resolve(true)), // Mirrors the real lookup chain (conversation namespace, then common). t: over?.t ?? makeTranslate(zh, commonZh), renderSlot, @@ -120,7 +126,7 @@ function bench(over?: BenchOptions) { const button = view.container.querySelector( `button[aria-label="${over?.running === true ? '停止生成' : '发送消息'}"]`, )! - return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls } + return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher } } describe('Enter semantics', () => { @@ -205,7 +211,7 @@ describe('running and lock semantics (queue cut 1)', () => { const { textarea, view } = bench({ disabled: true }) expect(textarea.disabled).toBe(true) expect(textarea.placeholder).toBe('会话不可用') - expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) }) it('idle primary sends and disables on empty draft', () => { @@ -438,10 +444,10 @@ describe('strips and variants', () => { }) }) -describe('placeholder chrome and control seats', () => { - it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { +describe('command launcher chrome and control seats', () => { + it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { const { view, slotCalls } = bench() - expect(view.getByLabelText('添加附件')).toBeTruthy() + expect(view.getByLabelText('命令')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^访问模式/)).toBeNull() // Both seats dispatched, nothing rendered. @@ -450,7 +456,47 @@ describe('placeholder chrome and control seats', () => { expect(view.queryByLabelText('Model')).toBeNull() }) - it('the Access chip renders the projection value and submits /permission on pick', async () => { + it('passes the textarea selection to the command menu launcher and reflects its expanded state', () => { + const toggleCommandMenu = vi.fn() + const { view, textarea, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu }) + textarea.setSelectionRange(2, 7) + const launcher = view.getByLabelText('命令') + expect(launcher.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(launcher) + expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 }) + act(() => { menuLauncher.set('command') }) + expect(launcher.getAttribute('aria-expanded')).toBe('true') + }) + + it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'read-only', name: 'read-only' }, + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'read-only', + } + const { view } = bench({ permissions, command }) + const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement + // Title-case display is presentation only; the menu ids stay machine names. + expect(trigger.textContent).toBe('Read Only') + fireEvent.click(trigger) + const items = view.getAllByRole('menuitem') + expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access']) + fireEvent.click(items[1]!) + // Optimistic pick + disable until admission resolves (command stub resolves true). + const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement + expect(busy.textContent).toBe('Workspace Write') + expect(busy.disabled).toBe(true) + expect(command).toHaveBeenCalledWith('/permission workspace-write') + await act(async () => {}) + expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false) + }) + + it('requires explicit risk acknowledgement before submitting Full access', async () => { + const command = vi.fn(() => Promise.resolve(true)) const permissions = { options: [ { value: 'workspace-write', name: 'workspace-write' }, @@ -458,20 +504,86 @@ describe('placeholder chrome and control seats', () => { ], currentValue: 'workspace-write', } - const { view } = bench({ permissions }) - const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement - // Title-case display is presentation only; the menu ids stay machine names. - expect(trigger.textContent).toBe('Workspace Write') - fireEvent.click(trigger) - const items = view.getAllByRole('menuitem') - expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access']) - fireEvent.click(items[1]!) - // Optimistic pick + disable until admission resolves (command stub resolves true). - const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement - expect(busy.textContent).toBe('Danger Full Access') - expect(busy.disabled).toBe(true) + const { view } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^访问模式/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + + expect(command).not.toHaveBeenCalled() + expect(view.getByRole('dialog', { name: '确认启用 Full access?' })).toBeTruthy() + const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement + expect(enable.disabled).toBe(true) + + fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' })) + expect(enable.disabled).toBe(false) + fireEvent.click(enable) + + expect(command).toHaveBeenCalledOnce() + expect(command).toHaveBeenCalledWith('/permission danger-full-access') + expect(view.queryByRole('dialog')).toBeNull() + expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access') await act(async () => {}) - expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false) + }) + + it('cancels a Full access selection without changing permission and resets acknowledgement', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view } = bench({ permissions, command }) + const openConfirmation = () => { + fireEvent.click(view.getByLabelText(/^访问模式/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + } + + openConfirmation() + fireEvent.click(view.getByRole('checkbox')) + fireEvent.click(view.getByRole('button', { name: '取消' })) + expect(command).not.toHaveBeenCalled() + expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write') + + openConfirmation() + expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false) + expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true) + }) + + it('revokes an open Full access confirmation when the task locks', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view, session } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^访问模式/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + fireEvent.click(view.getByRole('checkbox')) + act(() => { session.set(snapshotOf({ removed: true })) }) + expect(view.queryByRole('dialog')).toBeNull() + expect(command).not.toHaveBeenCalled() + }) + + it('resets an open Full access confirmation when switching tasks', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view, props } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^访问模式/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + fireEvent.click(view.getByRole('checkbox')) + view.rerender() + expect(view.queryByRole('dialog')).toBeNull() + expect(command).not.toHaveBeenCalled() }) it('a registered entry fills its seat and receives the locked owner prop', () => { @@ -489,10 +601,10 @@ describe('placeholder chrome and control seats', () => { expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true) }) - it('disabled locks the Access chip and attach control (running does not)', () => { + it('disabled locks the Access chip and command launcher (running does not)', () => { const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } const { view } = bench({ disabled: true, permissions }) - expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true) cleanup() const live = bench({ running: true, permissions }) diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index d3d1b97583..81201b456f 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -46,8 +46,10 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(createSnapshotStore(null)), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), command: () => Promise.resolve(true), @@ -175,7 +177,7 @@ describe('matrix row: locked (session disabled)', () => { it('disables the textarea and chrome; the machine currency is untouched', () => { const { view, textarea, shell } = bench({ disabled: true }) expect((textarea).disabled).toBe(true) - expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) expect(shell.snapshot.phase).toBe('plain') }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 5896bee7eb..ba076fd439 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -132,8 +132,18 @@ async function scopedBench(register?: (slash: SlashService) => void) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + toggleCommandMenu: (selection) => { + const snapshot = shell.snapshot + controller.toggleSource('command', { + trigger: '/', + query: '', + position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline', + span: { ...selection, draftRev: snapshot.draftRev }, + }) + }, useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), + useMenuLauncher: bindSnapshotSelector(controller.launcher), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), command: () => Promise.resolve(true), diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.tsx b/packages/client/ui-conversation/tests/selection-survival.spec.tsx index 70162105a9..4559618a8a 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.tsx +++ b/packages/client/ui-conversation/tests/selection-survival.spec.tsx @@ -103,7 +103,7 @@ describe('selection survives on the store seat', () => { // ...and a re-created same-id session starts from a FRESH instance. const reborn = storeFor(b, 'conversation.session', sid('s1')) expect(reborn).not.toBe(doomed) - expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null }) + expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null }) await b.runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index cf6d22491e..e0d51d5e11 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -152,8 +152,10 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} + toggleCommandMenu={vi.fn()} useNotices={bindSnapshotSelector(wiring.notices)} useLexicon={bindSnapshotSelector(wiring.lexicon)} + useMenuLauncher={bindSnapshotSelector(createSnapshotStore(null))} stop={stop} command={() => Promise.resolve(true)} t={t} diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 0f3ff4714e..be850f6a75 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -15,7 +15,7 @@ import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-conne import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts' +import { terminalCardModel, terminalFailed } from '../src/client/contract/terminal-card-model.ts' import { createChatStore } from '../src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx' import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' @@ -95,6 +95,19 @@ describe('terminalCardModel', () => { }))?.card.signal).toBe('SIGTERM') }) + it('flags a failing exit as terminalFailed; clean exits and running cards are not', () => { + // isError stays false on a failing command (the exit status is result + // data), so this predicate is the row's only failure signal. + expect(terminalFailed(terminalCardModel(settled({ + resultView: resultTerminal({ exitCode: 2 }), + }))!)).toBe(true) + expect(terminalFailed(terminalCardModel(settled({ + resultView: { card: 'terminal', output: '', signal: 'SIGTERM' }, + }))!)).toBe(true) + expect(terminalFailed(terminalCardModel(settled())!)).toBe(false) + expect(terminalFailed(terminalCardModel(running())!)).toBe(false) + }) + it('takes the result view\'s replacement title over the pending one', () => { // The presentation contract defines a result title as REPLACING the pending // title, so a tool that rewrites it at settle time must win here. @@ -229,36 +242,39 @@ describe('chat row terminal body', () => { callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t, }) - it('the expanded body is the command output, capped tighter than the panel', () => { - expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16) + /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ + const toggleRow = (view: { container: HTMLElement }) => { + fireEvent.click(view.container.querySelector('[data-expandable]')!) + } + + it('the expanded body is the command output inside the row scroll container', () => { const view = render() // Collapsed: the one-line summary row only, no output. expect(view.getByText('List files')).toBeTruthy() expect(view.queryByText(/a\.ts/)).toBeNull() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() expect(view.getByText('ls -la')).toBeTruthy() // The args JSON body the generic path would have shown is gone. expect(view.queryByText(/"command"/)).toBeNull() }) - it('the cap collapses a long output inside the row, expandable in place', () => { - const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`) + it('a long output renders in full — the scroll container replaces the middle collapse', () => { + const lines = Array.from({ length: 20 }, (_, i) => `line-${i}`) const view = render() - fireEvent.click(view.container.querySelector('button')!) - expect(view.getByText('… 其余 3 行')).toBeTruthy() - expect(view.queryByText('line-5')).toBeNull() - fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' })) + toggleRow(view) expect(view.getByText('line-5')).toBeTruthy() + expect(view.getByText('line-19')).toBeTruthy() + expect(view.queryByText(/其余/)).toBeNull() }) it('renders a multi-line command as one prompt row per line', () => { const view = render() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) const rows = view.container.querySelectorAll('[class^="_promptLine_"]') expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done']) // Still one dot for the call, on the first row. @@ -283,14 +299,14 @@ describe('chat row terminal body', () => { callView: callTerminal({ description: 'Terminal 3' }), }))} />) expect(view.getByText('Terminal 3')).toBeTruthy() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) expect(view.container.querySelector('[data-terminal]')).not.toBeNull() expect(view.getByText('Terminal 3')).toBeTruthy() }) it('a running terminal call expands to the prompt line with no output yet', () => { const view = render() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) expect(view.getByText('ls -la')).toBeTruthy() expect(view.queryByText('复制')).toBeNull() // The card states its own run state: a running command reads as running @@ -302,7 +318,7 @@ describe('chat row terminal body', () => { const view = render() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) expect(view.getByText(/"command"/)).toBeTruthy() }) @@ -311,9 +327,16 @@ describe('chat row terminal body', () => { const view = render() - fireEvent.click(view.container.querySelector('button')!) + toggleRow(view) expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() }) + + it('a failing exit status surfaces as the collapsed row\'s error state', () => { + const view = render() + expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error') + }) }) describe('BashRow terminal card', () => { @@ -330,14 +353,17 @@ describe('BashRow terminal card', () => { t, } as unknown as BashRowProps) - it('renders the command output under the summary row, without an expand gesture', () => { + it('collapses to the summary row; the whole row toggles the command output', () => { const view = render() expect(view.getByText('List files')).toBeTruthy() + expect(view.queryByText(/a\.ts/)).toBeNull() + fireEvent.click(view.container.querySelector('[data-expandable]')!) expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() - // The card's controls are the row's only interactions: a bash row is not a - // path link and no longer a details-panel target, so nothing here navigates. - expect(view.container.querySelector('[data-clickable]')).toBeNull() expect(view.getByText('复制')).toBeTruthy() + // Collapse back in place: the summary row returns, the card unmounts. + fireEvent.click(view.container.querySelector('[data-expandable]')!) + expect(view.queryByText(/a\.ts/)).toBeNull() + expect(view.getByText('List files')).toBeTruthy() }) // The row's leading StateDot and the card's run-state dot describe the same @@ -346,13 +372,22 @@ describe('BashRow terminal card', () => { it('agrees with the summary row about the run state', () => { const runningView = render() expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running') + fireEvent.click(runningView.container.querySelector('[data-expandable]')!) expect(runStateOf(runningView.container)).toBe('ongoing') cleanup() const settledView = render() expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok') + fireEvent.click(settledView.container.querySelector('[data-expandable]')!) expect(runStateOf(settledView.container)).toBe('done') }) + it('a failing exit status surfaces as the collapsed row\'s error state', () => { + const view = render() + expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error') + }) + it('shows the terminal presenter\'s description instead of the args summary', () => { // `terminal_send`-style presenters author a description the args do not // repeat; the contract puts it above the card, which is this row's summary. diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx new file mode 100644 index 0000000000..66eddae638 --- /dev/null +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -0,0 +1,270 @@ +// @vitest-environment jsdom +// The web render intent on the web side: the pure webCardModel derivation over +// resultView, and the conversation render sites that consume it — the keyed +// WebRow (registered under both web_search and web_fetch), the GenericToolCard +// render-site fallback, and the details panel's Output section. Mirrors +// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat +// row's resident card, the panel arm, and the keyed registration. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const SID = 's1' as SessionId + +/** Locale seat for the card render sites (GenericToolCard, DetailsPanel), as the sibling suites build it. */ +const t = makeTranslate(zh, commonZh) + +const SEARCH_ARGS = '{"query":"deepseek harness"}' +const FETCH_ARGS = '{"url":"https://example.com/page"}' + +/** A web_search result view; overrides tune the sources / answer / truncation. */ +const resultSearch = (over?: Partial>): ToolResultView => ({ + card: 'web', kind: 'search', truncated: false, + answer: 'A short answer.', + sources: [ + { url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' }, + { url: 'https://plain.example.org/b' }, + ], + ...over, +}) + +/** A web_fetch result view. */ +const resultFetch = (over?: Partial>): ToolResultView => ({ + card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over, +}) + +const runningSearch = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS, + turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over, +}) + +const settledSearch = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'web_search', argsRaw: SEARCH_ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'search text' }], isError: false, + callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over, +}) + +const settledFetch = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2', + call: { name: 'web_fetch', argsRaw: FETCH_ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'fetch body' }], isError: false, + callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over, +}) + +describe('webCardModel', () => { + it('derives a search card from the result view, projecting every source field', () => { + expect(webCardModel(settledSearch())).toEqual({ + kind: 'search', + answer: 'A short answer.', + truncated: false, + sources: [ + { url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' }, + { url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined }, + ], + }) + }) + + it('carries the search truncation flag and an absent answer', () => { + const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } })) + expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] }) + }) + + it('derives a fetch card from the result view', () => { + expect(webCardModel(settledFetch())).toEqual({ + kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, + }) + expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) }))) + .toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true }) + }) + + it('returns null for a running call, since the web card is result-only', () => { + expect(webCardModel(runningSearch())).toBeNull() + // Even a running call that somehow carried a web call view stays generic: + // the derivation reads resultView only. + expect(webCardModel(runningSearch({ callView: null }))).toBeNull() + }) + + it('returns null for a settled call whose result view is not a web card', () => { + expect(webCardModel(settledSearch({ resultView: null }))).toBeNull() + expect(webCardModel(settledSearch({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView + expect(webCardModel(settledSearch({ resultView: future }))).toBeNull() + // A web card whose kind this UI version does not know (a newer host's + // value) also takes the generic path, not a malformed fetch. + const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView + expect(webCardModel(settledSearch({ resultView: futureKind }))).toBeNull() + }) +}) + +describe('chat row web body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({ + callId: block.callId, toolName, block, openFile: vi.fn(), + }) + // WebRow reads only toolName/block off the full runtime share; the standard + // kit is unused, so the cast supplies the owner slice alone (as BashRow's + // tests do for the terminal card). + const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps => + ownerProps(block, toolName) as unknown as ToolRowProps + + it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => { + expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16) + const view = render() + // The summary row plus the resident card, without any expand gesture on the row itself. + expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('Titled')).toBeTruthy() + expect(view.getByText('excerpt')).toBeTruthy() + // hostname fallback for the source with no title + expect(view.getByText('plain.example.org')).toBeTruthy() + }) + + it('the WebRow renders the fetch card resident, titled Fetch', () => { + const view = render() + expect(view.getByText('Fetch')).toBeTruthy() + // The url shows in the summary row and as the card's link; scope to the card. + const card = view.container.querySelector('[data-web="fetch"]') + expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page') + expect(view.getByText('HTTP 200')).toBeTruthy() + }) + + it('a running web call is the summary row alone (no card until it settles)', () => { + const view = render() + expect(view.getByText('Search')).toBeTruthy() + expect(view.queryByText('Titled')).toBeNull() + expect(view.container.querySelector('[data-web]')).toBeNull() + }) + + it('a failed web call keeps the summary row without the card', () => { + const view = render() + expect(view.getByText('Search')).toBeTruthy() + expect(view.container.querySelector('[data-web]')).toBeNull() + // The row reflects the error state so the summary line still reads as failed. + expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() + }) + + it('the GenericToolCard fallback also renders a resident web card for a web-declaring tool', () => { + // A web-declaring tool without its own keyed row lands on the fallback; its + // card is resident there too. + const view = render() + expect(view.getByText('Titled')).toBeTruthy() + expect(view.container.querySelector('[data-web="search"]')).not.toBeNull() + }) + + it('the GenericToolCard fallback keeps the plain row for a non-web call', () => { + const view = render() + expect(view.container.querySelector('[data-web]')).toBeNull() + }) +}) + +describe('DetailsPanel web Output section', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready' }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + t={t} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + it('renders the search card at full source allowance', () => { + const view = mount(snapshot({ nodes: [settledSearch()] }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) + expect(view.getByText('Titled')).toBeTruthy() + expect(view.getByText('excerpt')).toBeTruthy() + // The Input JSON section survives beside it. + expect(view.getByText(/"query"/)).toBeTruthy() + }) + + it('renders the fetch card and keeps the fetched body below it', () => { + const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' }) + const card = view.container.querySelector('[data-web="fetch"]') + expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page') + expect(view.getByText('HTTP 200')).toBeTruthy() + // The card is a summary (URL + status only); the panel is the single-call + // reading surface, so the fetched body still renders below the card. + const output = view.getByText('输出').closest('section') + expect(output?.querySelector('pre')?.textContent).toContain('fetch body') + }) + + it('a non-web result keeps the flattened pre form', () => { + const view = mount(snapshot({ + nodes: [settledSearch({ callView: null, resultView: null })], + }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) + expect(view.container.querySelector('[data-web]')).toBeNull() + const output = view.getByText('输出').closest('section') + expect(output?.querySelector('pre')?.textContent).toContain('search text') + }) +}) + +describe('web toolview registration', () => { + it('registers one WebRow under both web_search and web_fetch', () => { + const registered: { key: string; component: unknown }[] = [] + const ctx = { + slots: { + register: (options: { name: string; key: string }, component: unknown) => { + registered.push({ key: options.key, component }) + return () => {} + }, + }, + } as unknown as import('cordis').Context + webToolview.apply(ctx) + expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch']) + // One component under both keys, not two thin rows. + expect(registered[0]?.component).toBe(WebRow) + expect(registered[1]?.component).toBe(WebRow) + // The load-order seam the render site depends on. + expect(webToolview.inject).toEqual(['slots', 'conversation']) + }) +}) diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index cee54f104c..ee2d789500 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-command" ], @@ -35,6 +36,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-command": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", @@ -43,6 +45,7 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index 30fc6d2dd5..d5561afe32 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -8,15 +8,21 @@ * projection (the same host-computed select the composer chip renders); a * pick submits the `/permission ` command line, so both surfaces * write through one path and the pushed projection frame is the one - * confirmation. + * confirmation. The Full access row carries the same explicit risk gate as + * the composer chip; the shared popup shell owns the modal mechanics. */ import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' /** Required services (cordis fiber inject). */ -export const inject = ['command', 'sessions'] +export const inject = ['command', 'sessions', 'locale'] + +const FULL_ACCESS = 'danger-full-access' +const ACCESS_NS = 'permission.access' /** Read one session's current permissions projection value (undefined = capability absent). */ function selectOf(session: SessionFace | undefined): PermissionSelect | undefined { @@ -26,8 +32,9 @@ function selectOf(session: SessionFace | undefined): PermissionSelect | undefine /** * Display transform twin of the composer chip's (ui-conversation * PermissionSelect): kebab-case machine names render as title-case labels - * (`workspace-write` → `Workspace Write`) so both permission surfaces show - * the same text; non-kebab host-configured names pass through. + * (`workspace-write` → `Workspace Write`); non-kebab host-configured names + * pass through. Full access intentionally uses the product label rather than + * a title-cased machine value; its warning body remains locale-aware. */ function displayName(name: string): string { if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name @@ -35,14 +42,25 @@ function displayName(name: string): string { } /** Flatten the projection select into popup rows; `custom` is display state, never a target. */ -function optionsOf(value: PermissionSelect): SelectOption[] { +function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectOption[] { return value.options .filter(option => option.value !== 'custom') .map(option => ({ id: option.value, - label: displayName(option.name), + label: option.value === FULL_ACCESS ? 'Full access' : displayName(option.name), ...(option.description !== undefined ? { detail: option.description } : {}), ...(option.value === value.currentValue ? { active: true } : {}), + ...(option.value === FULL_ACCESS + ? { + confirmation: { + title: t('confirm.title'), + description: t('confirm.description'), + acknowledgeLabel: t('confirm.acknowledge'), + cancelLabel: t('confirm.cancel'), + confirmLabel: t('confirm.enable'), + }, + } + : {}), })) } @@ -54,6 +72,30 @@ function optionsOf(value: PermissionSelect): SelectOption[] { export function apply(ctx: ClientContext): void { const command = ctx.get('command') as CommandServiceContract const sessions = ctx.sessions + // This optional bundle and ui-conversation can load independently, so each + // owns the same safety copy under its own locale namespace. + /* jscpd:ignore-start */ + ctx.effect(() => { + const disposers = [ + ctx.locale.register(ACCESS_NS, 'zh', { + 'confirm.title': '确认启用 Full access?', + 'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', + 'confirm.acknowledge': '我已了解风险,并愿意继续', + 'confirm.cancel': '取消', + 'confirm.enable': '启用 Full access', + }), + ctx.locale.register(ACCESS_NS, 'en', { + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', + }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-permission: Full access confirmation dictionaries') + /* jscpd:ignore-end */ + const t = ctx.locale.bind(ACCESS_NS) const sessionFor = (session: ClientSessionContext): SessionFace | undefined => sessions.binding(session.sessionId)?.session ctx.effect(() => command.decorate({ @@ -67,7 +109,7 @@ export function apply(ctx: ClientContext): void { options: (session) => { const value = selectOf(sessionFor(session)) if (value === undefined) throw new Error('permission presets are not available on this host') - return Promise.resolve(optionsOf(value)) + return Promise.resolve(optionsOf(value, t)) }, onSelect: async (option, session) => { const live = sessionFor(session) diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 5f9125db53..f8fde6d10d 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -54,6 +54,17 @@ async function bench() { ctx.provide('sessions', { binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined), }) + const en = { + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access can perform sensitive operations.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', + } as Record + ctx.provide('locale', { + register: () => () => {}, + bind: () => (key: string) => en[key] ?? key, + }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { @@ -86,7 +97,14 @@ describe('ui-permission browser plugin', () => { expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true) expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.') // Kebab-case names title-case; non-kebab host-configured names pass through. - expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Danger Full Access']) + expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access']) + expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({ + title: 'Enable Full access?', + description: 'Full access can perform sensitive operations.', + acknowledgeLabel: 'I understand the risks and want to continue', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }) b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] }) const passthrough = await c.ui.options(proj, new AbortController().signal) expect(passthrough[0]?.label).toBe('Ask Every Time') diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 0f7ef9584a..772d65f86d 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md -README.md: 568539c19331cc268217ee2c28b928c38a68323c -README.zh.md: 68e3092ad77267a779d21ba627ce2f19469ae05b +README.md: fcc4fbab4fbe1a8cc27119366b21ef55c669ba30 +README.zh.md: b618199616e45f69d62f3507c96d367bb3b9909f diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 568539c193..fcc4fbab4f 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Plan-mode status chip, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat (to the right of the access-mode control); the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-or-idle-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster. -Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `conversation` locale namespace (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win). +Plan mode is entered through the `/plan` command path: users can choose Plan from the composer's `+` Command menu or type `/plan`, while this package renders no inactive plan control. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders the warn-colored "Plan ×" status button, which executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `conversation` locale namespace (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win). The chip carries the accessible description "Plan mode on, press to turn off". Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit. @@ -22,4 +22,4 @@ Entering or leaving plan mode changes the active `plan:policy` system-prompt sec - **Plan mode is guidance, not an execution sandbox** — deployments that require enforced read-only planning must compose the independent sandbox and approval policies. - **The chip belongs to the default composer** — a pending whole-composer interaction such as plan review temporarily replaces the InputBar and its chip. -- **No UI entry point** — plan mode is entered by typing `/plan`; a session with the capability but inactive mode shows no affordance in the tool row. +- **No inactive plan control** — entry uses the shared Command source; a session with the capability but inactive mode shows no plan affordance in the tool row. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 68e3092ad7..b618199616 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -4,7 +4,7 @@ Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。 -plan mode 只经 `/plan` 命令进入;UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chip,hover 出现的 × 经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 +plan mode 经 `/plan` 命令路径进入:用户可以从 composer 的 `+` Command 菜单选择 Plan,也可以输入 `/plan`,而本包(package)不渲染未激活态 plan 控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染 warn 色的 "Plan ×" 状态按钮,该按钮经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。 @@ -22,4 +22,4 @@ chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`m - **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。 - **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。 -- **无 UI 进入点**——plan mode 靠敲 `/plan` 进入;有能力但未激活的会话在工具行不显示任何入口。 +- **无未激活态 plan 控件**——入口使用共享 Command source;有能力但 mode 未激活的会话在工具行不显示 plan 入口。 diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index ead0b229c8..f80a2be1ae 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -40,6 +40,7 @@ "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-plan-mode": "^0.0.1", @@ -52,6 +53,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index f79e9073db..46e893aee8 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -1,5 +1,4 @@ -/* Plan-mode toggle chip: quiet while off; the pressed state takes the - business accent pair (same token pairing as the trajectory user badge). */ +/* Active plan status follows Figma's warn-state pill. */ .wrap { display: inline-flex; @@ -10,30 +9,25 @@ .chip { display: inline-flex; align-items: center; - padding: 4px 8px; + gap: 4px; + min-width: 34px; + padding: 2px 8px; border: none; - border-radius: 8px; - background: transparent; - color: var(--dsw-alias-label-secondary); - font-size: 14px; + border-radius: 999px; + background: var(--dsw-alias-state-warn-tertiary); + color: var(--dsw-alias-state-warn-label); + font-size: 13px; + font-weight: 500; line-height: 20px; cursor: pointer; } .chip:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -/* Hovering keeps the pressed accent: the higher-specificity hover rule above - would otherwise swap it back to the neutral hover wash. */ -.chip[aria-pressed='true'], -.chip[aria-pressed='true']:hover:not(:disabled) { - color: var(--dsw-alias-state-business-primary); - background: var(--dsw-alias-state-business-tertiary); + color: var(--dsw-alias-state-warn-primary); } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-label-secondary); + outline: 2px solid var(--dsw-alias-state-warn-label); outline-offset: 2px; } @@ -42,6 +36,12 @@ cursor: default; } +.close { + display: inline-flex; + align-items: center; + color: currentColor; +} + .error { color: var(--dsw-alias-state-error-primary); font-size: 12px; diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index cccae3a6db..6573c6aeb1 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and // its {locked} owner share). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -11,16 +12,14 @@ export type PlanChipProps = PropsRuntime<'conversation.input.plan'> & InjectFace & PropsLocale<'plan'> /** - * Plan-mode toggle over the host-computed `plan` projection. The chip renders - * whenever the capability is present and reflects the effective target as its - * pressed state (`pending ? !active : active` — a folded host value, not - * client optimism, so an arriving frame corrects it). Clicking executes - * /plan or /plan off toward the opposite target. + * Plan-mode status over the host-computed `plan` projection. The chip renders + * only while the effective target is plan mode (`pending ? !active : active` + * — a folded host value, not client optimism) and executes /plan off. */ -export function PlanChip({ useProjection, locked, setPlanMode, t }: PlanChipProps) { +export function PlanChip({ useProjection, locked, exitPlanMode, t }: PlanChipProps) { const plan = useProjection('plan') - const [busy, setBusy] = useState(false) - const [error, setError] = useState<{ text: string; detail: string } | null>(null) + const [leaving, setLeaving] = useState(false) + const [error, setError] = useState(null) const aliveRef = useRef(true) useEffect(() => { @@ -30,26 +29,22 @@ export function PlanChip({ useProjection, locked, setPlanMode, t }: PlanChipProp } }, []) - // Absent capability (no plan-mode host plugin / no session yet): no seat - // content — without the capability there is nothing to toggle. if (plan === undefined) return null const target = plan.pending ? !plan.active : plan.active + if (!target) return null - const toggle = (): void => { - // No busy/locked guard: both disable the button, so no click arrives. - // Failure copy stays English (error-surface policy: not localized). - const on = !target - const failText = on ? 'failed to enter plan mode' : 'failed to exit plan mode' - setBusy(true) + const off = (): void => { + // No leaving/locked guard: both disable the button, so no click arrives. + setLeaving(true) setError(null) - void setPlanMode(on).then((failure) => { + void exitPlanMode().then((failure) => { if (!aliveRef.current) return - setBusy(false) - setError(failure === null ? null : { text: failText, detail: failure }) + setLeaving(false) + setError(failure) }, (reason: unknown) => { if (!aliveRef.current) return - setBusy(false) - setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) }) + setLeaving(false) + setError(reason instanceof Error ? reason.message : String(reason)) }) } @@ -58,16 +53,19 @@ export function PlanChip({ useProjection, locked, setPlanMode, t }: PlanChipProp - {error !== null && {error.text}} + {/* Failure copy stays English (error-surface policy: not localized). */} + {error !== null && failed to exit plan mode} ) } diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index f65085faa9..64e87ae951 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -1,11 +1,11 @@ /** * Plan control plugin, browser half: occupies the composer's named - * `conversation.input.plan` seat with a plan-mode toggle chip. While the - * `plan` projection is present the chip renders in both states and executes - * /plan or /plan off through `command.execute` toward the opposite target; - * an absent projection (no capability) leaves the seat empty. Reads ride the - * generic projection pair through the standard-kit `useProjection` (an absent - * key is capability absence); zero client-side plan state. + * `conversation.input.plan` seat with an active-state status chip. Plan mode + * is entered through the command source; while the projection's effective + * target is plan mode the chip renders and executes /plan off through + * `command.execute`, otherwise the seat stays empty. Reads ride the generic + * projection pair through the standard-kit `useProjection`; zero client-side + * plan state. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -33,11 +33,10 @@ const NS = 'plan' /** Injected business face of the composer plan seat. */ export interface PlanChipInjected { /** - * Switch plan mode by executing /plan (on) or /plan off. - * @param on - desired target: true enters plan mode, false leaves it. + * Leave plan mode by executing /plan off. * @returns null on admitted execution; a user-visible failure line otherwise. */ - setPlanMode: (on: boolean) => Promise + exitPlanMode: () => Promise } /** @@ -59,12 +58,11 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: (sessionId: SessionId): PlanChipInjected => ({ // Failure strings stay English (error-surface policy: not localized). - setPlanMode: async (on) => { - const line = on ? '/plan' : '/plan off' + exitPlanMode: async () => { const connection = ctx.get('connection') as ConnectionHandle - const { result } = await connection.api.commands.execute({ sessionId, line }) + const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' }) if (!result.ok) return `${result.error.message} (${result.error.code})` - if (!result.value.matched) return `unknown command: ${line}` + if (!result.value.matched) return 'unknown command: /plan off' return null }, }), diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index e719e132a8..4f028724ea 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -1,9 +1,9 @@ /** * ui-plan browser half on a real SlotsService: the plugin occupies the - * conversation-declared `conversation.input.plan` single seat with the plan - * toggle chip; the injected face executes /plan or /plan off by direction and - * folds admission outcomes into null (admitted) or a user-visible failure - * line; teardown empties the seat (HMR safety). + * conversation-declared `conversation.input.plan` single seat with the active + * plan status chip; the injected face executes /plan off and folds admission + * outcomes into null (admitted) or a user-visible failure line; teardown + * empties the seat (HMR safety). */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' @@ -52,7 +52,7 @@ describe('ui-plan browser apply', () => { .rejects.toThrow(/slot "conversation.input.plan" is not declared/) }) - it('registers the chip, executes /plan by direction, and unregisters on teardown', async () => { + it('registers the chip, executes /plan off, and unregisters on teardown', async () => { const b = await bench() const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -60,22 +60,20 @@ describe('ui-plan browser apply', () => { expect(entry.component).toBe(PlanChip) const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) - await expect(injected.setPlanMode(false)).resolves.toBeNull() + await expect(injected.exitPlanMode()).resolves.toBeNull() expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) - await expect(injected.setPlanMode(true)).resolves.toBeNull() - expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' }) // Business failure folds to the composer-visible line. b.execute.mockResolvedValueOnce({ result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, } as never) - await expect(injected.setPlanMode(false)).resolves.toBe('gone (session-not-found)') + await expect(injected.exitPlanMode()).resolves.toBe('gone (session-not-found)') // Unmatched admission (plan-mode not composed host-side) is also a failure line. b.execute.mockResolvedValueOnce({ result: { ok: true as const, value: { matched: false as const } }, } as never) - await expect(injected.setPlanMode(true)).resolves.toBe('unknown command: /plan') + await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off') await fiber.dispose() expect(b.slots.entries('conversation.input.plan')).toHaveLength(0) diff --git a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx index f4752559ef..2ecbbda063 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.spec.tsx @@ -1,11 +1,9 @@ // @vitest-environment jsdom /** * PlanChip over the `plan` projection: nothing renders while the capability - * is absent; with the capability present the chip renders in both states with - * aria-pressed following the effective target (pending folds — /plan shows - * pressed immediately, /plan off unpressed immediately); clicking executes - * the command toward the opposite target and surfaces direction-specific - * failures while the projection still owns the displayed state. + * is absent or the effective target is the default mode; while plan mode is + * the target, the chip executes /plan off and remains visible through failures + * until the projection confirms the exit. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -24,98 +22,74 @@ const t: PlanChipProps['t'] = makeTranslate(zh, commonZh) function setup( plan: PlanProjection | undefined, - setPlanMode = vi.fn((_on: boolean) => Promise.resolve(null)), + exitPlanMode = vi.fn(() => Promise.resolve(null)), locked = false, ) { const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan }) const useProjection = (_key: string, selector?: (v: unknown) => unknown) => bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value)) - const props = { useProjection, locked, setPlanMode, t } as unknown as PlanChipProps + const props = { useProjection, locked, exitPlanMode, t } as unknown as PlanChipProps const view = render() - return { store, setPlanMode, view } + return { store, exitPlanMode, view } } -const onChip = () => screen.getByRole('button', { name: 'plan mode 已开启,按下关闭' }) -const offChip = () => screen.getByRole('button', { name: 'plan mode 已关闭,按下开启' }) +const chip = () => screen.getByRole('button', { name: 'plan mode 已开启,按下关闭' }) describe('PlanChip', () => { - it('renders nothing while the capability is absent', () => { + it('renders nothing for an absent capability or a default-mode target', () => { const absent = setup(undefined) expect(absent.view.container.innerHTML).toBe('') + cleanup() + const inactive = setup({ active: false, pending: false }) + expect(inactive.view.container.innerHTML).toBe('') + cleanup() + const leaving = setup({ active: true, pending: true }) + expect(leaving.view.container.innerHTML).toBe('') }) - it('reflects the effective target as the pressed state, folding pending', () => { - setup({ active: false, pending: false }) - expect(offChip().getAttribute('aria-pressed')).toBe('false') - cleanup() + it('renders the Plan status for active and pending-entry targets', () => { setup({ active: true, pending: false }) - expect(onChip().getAttribute('aria-pressed')).toBe('true') + expect(chip().textContent).toBe('Plan') cleanup() - // /plan just ran (command/run folded, plan/mode not yet): target is plan. setup({ active: false, pending: true }) - expect(onChip().getAttribute('aria-pressed')).toBe('true') - cleanup() - // Active with a pending exit: the target is default — already unpressed. - setup({ active: true, pending: true }) - expect(offChip().getAttribute('aria-pressed')).toBe('false') + expect(chip().textContent).toBe('Plan') }) - it('unpressed chip executes /plan (on) once and follows the projection up', async () => { + it('executes /plan off once and follows the projection down', async () => { let resolve!: (value: string | null) => void - const setPlanMode = vi.fn((_on: boolean) => new Promise((done) => { resolve = done })) - const { store } = setup({ active: false, pending: false }, setPlanMode) - fireEvent.click(offChip()) - expect(setPlanMode).toHaveBeenCalledTimes(1) - expect(setPlanMode).toHaveBeenLastCalledWith(true) - // Busy while its own call is in flight. - fireEvent.click(offChip()) - expect(setPlanMode).toHaveBeenCalledTimes(1) + const exitPlanMode = vi.fn(() => new Promise((done) => { resolve = done })) + const { store } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) + fireEvent.click(chip()) + expect(exitPlanMode).toHaveBeenCalledTimes(1) resolve(null) - // The command's run record folds: target flips, the chip presses. - store.set({ value: { active: false, pending: true } }) - await waitFor(() => { - expect(onChip().getAttribute('aria-pressed')).toBe('true') - }) - }) - - it('pressed chip executes /plan off and follows the projection down', async () => { - const setPlanMode = vi.fn((_on: boolean) => Promise.resolve(null)) - const { store } = setup({ active: true, pending: false }, setPlanMode) - fireEvent.click(onChip()) - expect(setPlanMode).toHaveBeenLastCalledWith(false) store.set({ value: { active: true, pending: true } }) await waitFor(() => { - expect(offChip().getAttribute('aria-pressed')).toBe('false') + expect(screen.queryByRole('button', { name: 'plan mode 已开启,按下关闭' })).toBeNull() }) }) it('disables under the locked owner prop', () => { setup({ active: true, pending: false }, vi.fn(), true) - expect((onChip() as HTMLButtonElement).disabled).toBe(true) + expect((chip() as HTMLButtonElement).disabled).toBe(true) }) - it('surfaces direction-specific admission and transport failures while staying visible', async () => { - const exitFailing = vi.fn() + it('surfaces admission and transport failures while staying visible', async () => { + const exitPlanMode = vi.fn() .mockResolvedValueOnce('host said no') .mockRejectedValueOnce(new Error('network down')) .mockRejectedValueOnce('socket closed') - setup({ active: true, pending: false }, exitFailing) - fireEvent.click(onChip()) + setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) expect((await screen.findByText('failed to exit plan mode')).getAttribute('title')).toBe('host said no') - expect(onChip()).toBeTruthy() + expect(chip()).toBeTruthy() - fireEvent.click(onChip()) + fireEvent.click(chip()) expect(await screen.findByTitle('network down')).toBeTruthy() - fireEvent.click(onChip()) + fireEvent.click(chip()) expect(await screen.findByTitle('socket closed')).toBeTruthy() - cleanup() - - const enterFailing = vi.fn().mockResolvedValueOnce('agent busy') - setup({ active: false, pending: false }, enterFailing) - fireEvent.click(offChip()) - expect((await screen.findByText('failed to enter plan mode')).getAttribute('title')).toBe('agent busy') - expect(offChip()).toBeTruthy() }) it('ignores in-flight fulfillment and rejection after unmount', () => { @@ -124,14 +98,14 @@ describe('PlanChip', () => { { active: true, pending: false }, vi.fn(() => new Promise((done) => { resolve = done })), ) - fireEvent.click(onChip()) + fireEvent.click(chip()) successful.view.unmount() expect(() => { resolve(null) }).not.toThrow() let reject!: (reason: unknown) => void - const setPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) - const { view } = setup({ active: true, pending: false }, setPlanMode) - fireEvent.click(onChip()) + const exitPlanMode = vi.fn(() => new Promise((_done, fail) => { reject = fail })) + const { view } = setup({ active: true, pending: false }, exitPlanMode) + fireEvent.click(chip()) view.unmount() expect(() => { reject(new Error('late')) }).not.toThrow() }) diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index aa145c3d04..0772c23b31 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index c440159ba6..d55fe45007 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 4075f0e7472141b5d41fe0f51c1a620eae913bfb -README.zh.md: 7fd9529e597bc473a7c35fc3614f21f84cd19f44 +README.md: 58be01d56a85c66a144df3f8054840961e987403 +README.zh.md: 2efbec77e64d664553e93b5a8f8dcd2ec7fce49e diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4075f0e747..58be01d56a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), and TerminalBlock. Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, and WebBlock. Contract: api-contracts v3 §8. ## Markdown rendering @@ -12,6 +12,14 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ `TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +## Diff rendering + +`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md). + +## Web retrieval + +`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `
  • `, and the expand control is a marker-less `
  • ` so the `
      ` stays valid HTML. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `
        ` (the chat row does not surface the raw result content). A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md). + ## Model Experience None, as the package renders pure React atoms in the browser; nothing here reaches a model request. @@ -25,5 +33,5 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. -- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. +- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 7fd9529e59..2efbec77e6 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock。契约:api-contracts v3 §8。 +纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock,以及 WebBlock。契约:api-contracts v3 §8。 ## Markdown 渲染 @@ -11,6 +11,14 @@ `TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 +## Diff 渲染 + +`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `,error token)在新增行(`+ `,success token)之上、同文件第二个 hunk 前一个 `⋯` gap,以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16,与 `TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap),使多文件复制保持可归属,并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock`。`+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。 + +## Web 检索 + +`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind(由 `kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer(通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL)时回退到原始 URL,因此标签绝不为空;其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16,即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `
      1. ` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `
      2. `,使 `
          ` 保持为合法 HTML。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `
            `(chat 行不呈现原始 result content)。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。 + ## 模型体验 无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 @@ -24,5 +32,5 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 -- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。 +- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 diff --git a/packages/client/ui-primitives/src/DiffBlock.module.css b/packages/client/ui-primitives/src/DiffBlock.module.css new file mode 100644 index 0000000000..c5b79006a3 --- /dev/null +++ b/packages/client/ui-primitives/src/DiffBlock.module.css @@ -0,0 +1,107 @@ +/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface + + banner row, markdown code-block font) so a diff card reads as one family with + a fenced block and a terminal card. The deliberate divergence, shared with + TerminalBlock: the body keeps `white-space: pre` and scrolls horizontally, + because folding a source line destroys the indentation a diff is read by. */ + +.block { + --dsl-diff-radius: 12px; + --dsl-diff-line-height: 22px; + + position: relative; + margin: 16px 0; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-markdown-code-block); + border-radius: var(--dsl-diff-radius); +} + +/* The copy control floats in the top-right corner over the body, so the card + has no empty banner row above its first diff line (the TUI diff card has no + banner either — only the footer). The block is position: relative, so this + anchors to the card. */ +.copyButton { + position: absolute; + top: 8px; + right: 12px; + z-index: 1; + background-color: transparent; + border: none; + padding: 0; + margin: 0; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + font: var(--dsw-font-xs-13); +} + +.body { + padding: 12px 14px; + font: var(--dsw-font-markdown-code-block); + overflow-x: auto; + overflow-y: hidden; +} + +/* No wrapping, no word-break: a diff is read by its indentation. */ +.line { + min-height: var(--dsl-diff-line-height); + white-space: pre; +} + +/* A file header: the path in the primary tone, set apart by weight. The copy + button floats over this first row's top-right corner, so reserve space at the + line's end for it — a long path scrolls under the button otherwise, and the + button's hit area would eat clicks on the path's tail. */ +.path { + color: var(--dsw-alias-label-primary); + font-weight: 600; + padding-right: 56px; +} + +/* A same-file second hunk's separator (a scattered edit), in the dim tone. */ +.gap { + color: var(--dsw-alias-label-tertiary); +} + +/* The diff's own meaning-carrying colors: removed on the error token, added on + the success token. A `- `/`+ ` prefix is drawn here so a copied line and the + shown line agree, and so the sign reads without relying on color alone. */ +.del::before { + content: '- '; + color: var(--dsw-alias-state-error-primary); +} + +.del { + color: var(--dsw-alias-state-error-primary); +} + +.add::before { + content: '+ '; + color: var(--dsw-alias-state-success-primary); +} + +.add { + color: var(--dsw-alias-state-success-primary); +} + +.expand { + display: block; + width: 100%; + padding: 0; + border: none; + background-color: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + font: inherit; + text-align: left; +} + +.expand:hover { + color: var(--dsw-alias-label-secondary); +} + +/* The change summary, dim under the body: `└ +A -R · N file(s)`, the same + footer the TUI transcript's diff card draws. */ +.footer { + padding: 0 14px 12px; + font: var(--dsw-font-markdown-code-block); + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx new file mode 100644 index 0000000000..23c498b1d1 --- /dev/null +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -0,0 +1,196 @@ +// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy +// control over one or more per-file hunks, each a bold path header followed by +// the removed block (`-`, error color) and the added block (`+`, success +// color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors +// the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads +// the same across front ends: the removed side is the old text in full, the +// added side the new text in full, both split on the same terminator rule, and +// the footer counts distinct paths on both ends. Output never soft-wraps — an +// aligned source line keeps its indentation and scrolls horizontally instead of +// folding. Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock. + +import { useCallback, useMemo, useState } from 'react' +import clsx from 'clsx' +import { writeClipboard } from './clipboard.ts' +import css from './DiffBlock.module.css' + +/** + * Output lines shown before the height cap collapses the middle. Matches + * {@link DEFAULT_TERMINAL_MAX_LINES} so a diff card and a terminal card cut a + * long body at the same place. + */ +export const DEFAULT_DIFF_MAX_LINES = 16 + +/** + * One file's change, in the shape {@link DiffBlock} draws. Structurally the + * render-intent contract's `FileDiff`, redeclared here so this primitive stays + * free of the tool contract (the terminal card's decoupling, applied to diffs). + */ +export interface DiffHunk { + /** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */ + path: string + /** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */ + oldText: string | null + /** Content after the change (the added side). */ + newText: string +} + +export interface DiffBlockProps { + /** One entry per applied hunk, in file order; empty renders nothing. */ + diffs: DiffHunk[] + /** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */ + maxLines?: number | undefined + /** Extra class merged onto the wrapper (callers position; this component draws). */ + className?: string | undefined +} + +/** A single rendered body line and its role, so the height cap slices a flat list. */ +interface DiffRow { + kind: 'path' | 'del' | 'add' | 'gap' + text: string +} + +/** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if a row kind is forged */ +function assertNever(value: never): never { + throw new Error(`unreachable diff row kind: ${String(value)}`) +} + +/** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */ +const ROW_CLASS: Record = { + path: css.path, + del: css.del, + add: css.add, + gap: css.gap, +} + +/** + * Flatten the hunks into the body's rows plus the footer counts. A path header + * opens each new file; a same-file second hunk (a scattered edit) opens with a + * `⋯` gap instead of repeating the path. Every old-side line counts toward + * `removed` and every new-side line toward `added`. The file count is of + * DISTINCT paths, matching the TUI diff card's footer, so two hunks in one file + * read as `1 file` on both front ends. + * @param diffs - the hunks to render. + * @returns the body rows, the +/- totals, and the distinct-file count. + */ +function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } { + const rows: DiffRow[] = [] + const paths = new Set() + let added = 0 + let removed = 0 + let prevPath: string | undefined + for (const diff of diffs) { + paths.add(diff.path) + if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path }) + else rows.push({ kind: 'gap', text: '⋯' }) + prevPath = diff.path + if (diff.oldText !== null) { + for (const line of contentLines(diff.oldText)) { + rows.push({ kind: 'del', text: line }) + removed++ + } + } + for (const line of contentLines(diff.newText)) { + rows.push({ kind: 'add', text: line }) + added++ + } + } + return { rows, added, removed, files: paths.size } +} + +/** + * Split a side's text into its content lines. Empty text is zero lines (a full + * deletion's `newText` or a create's absent `oldText` side draws nothing), and a + * single trailing newline is a line terminator rather than an extra empty line — + * the same terminator rule TerminalBlock applies to command output. An interior + * blank line (a genuine `\n\n`) survives. + * @param text - the removed or added side's text. + * @returns the content lines, without the terminating newline. + */ +function contentLines(text: string): string[] { + if (text === '') return [] + const body = text.endsWith('\n') ? text.slice(0, -1) : text + return body.split('\n') +} + +/** + * The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its + * content, exactly what the card shows. The removed and added blocks are the + * change; the path headers keep a multi-file copy attributable. + * @param rows - the flattened body rows. + * @returns the diff as plain text. + */ +function copyText(rows: DiffRow[]): string { + return rows.map((row) => { + switch (row.kind) { + case 'del': return `- ${row.text}` + case 'add': return `+ ${row.text}` + case 'path': return row.text + case 'gap': return row.text + /* v8 ignore next -- closed-union backstop; only reached if a row kind is forged */ + default: return assertNever(row.kind) + } + }).join('\n') +} + +/** + * Render a file mutation as an inline diff surface. + * @param props - see {@link DiffBlockProps}. + * @returns the diff block element. + */ +export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) { + const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs]) + const [expanded, setExpanded] = useState(false) + const [copied, setCopied] = useState(false) + + const onCopy = useCallback(() => { + if (copied) return + void writeClipboard(copyText(rows)).then((ok) => { + if (!ok) return + setCopied(true) + window.setTimeout(() => { setCopied(false) }, 1000) + }) + }, [copied, rows]) + + const onToggle = useCallback(() => { setExpanded(value => !value) }, []) + + if (rows.length === 0) return null + + const hidden = rows.length - maxLines + const capped = hidden > 0 && !expanded + // Same split arithmetic as TerminalBlock and the TUI transcript's collapsed + // card, so a body's head and tail slices agree across the front ends. + const headLines = Math.ceil(maxLines / 2) + const tailLines = maxLines - headLines + const head = capped ? rows.slice(0, headLines) : rows + const tail = capped ? rows.slice(rows.length - tailLines) : [] + + return ( +
            + +
            + {head.map((row, index) => ( +
            {row.text}
            + ))} + {hidden > 0 && ( + + )} + {tail.map((row, index) => ( +
            {row.text}
            + ))} +
            +
            └ +{added} -{removed} · {files} file{files === 1 ? '' : 's'}
            +
            + ) +} diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 4e8b7d99d8..778fb82387 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,9 +1,11 @@ // Modal: controlled full-viewport dialog (create-workspace and similar). -// Fixed overlay in the React tree (no react-dom portal) so ui-primitives -// stays free of a react-dom dependency; mask tokens match figma 451:18655. +// The overlay portals to this document's body so ancestor stacking contexts +// cannot leave sticky page controls above the mask. This is still an in-page +// WebUI dialog; it never creates or targets another browser/native window. import { useEffect } from 'react' import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutline16 } from './icons/index.tsx' import css from './Modal.module.css' @@ -17,6 +19,7 @@ import css from './Modal.module.css' * @param props.description - optional supporting sentence under the title. * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). + * @param props.contentClassName - optional class for a scrollable content region. * @param props.headless - render children directly in the card (no default * header/close/body chrome) for dialogs whose figma frame owns its own * header structure; mask, card, Escape, and aria-label remain. @@ -25,7 +28,7 @@ import css from './Modal.module.css' * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel = 'Close', description, children, footer, className, headless = false, + open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false, }: { open: boolean onClose: () => void @@ -35,6 +38,7 @@ export function Modal({ children?: ReactNode footer?: ReactNode className?: string + contentClassName?: string headless?: boolean }) { useEffect(() => { @@ -48,7 +52,7 @@ export function Modal({ if (!open) return null - return ( + return createPortal((