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-30-session-end-seed-log-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml index d0d8d7b82f..cc25329fed 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.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-session-end-seed-log-boundary.md -2026-07-30-session-end-seed-log-boundary.md: 837531ba0bd3ecf404eb47ee933438546c682a54 -2026-07-30-session-end-seed-log-boundary.zh.md: 33680c1845364de62e5b53ead13de418a389f908 +2026-07-30-session-end-seed-log-boundary.md: 268646e192d0b8e0a5dde03957a18ef155b7038e +2026-07-30-session-end-seed-log-boundary.zh.md: dca87e16de5e567ff85d2b32b8243f76ebed1c4a diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md index 837531ba0b..268646e192 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.md @@ -14,13 +14,13 @@ Crash repair does not close the gap and must not: `interruptedTurnClosers` synth ## Decision -`Session`'s constructor appends the log-only `session/end-seed` event immediately after a non-empty constructor seed, as the seeded session's first live write at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers where this lifecycle's writes start for a consumer holding the object, while `session/end-seed` answers the same question for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history. +`Session`'s constructor appends the log-only `session/end-seed` event immediately after an explicitly supplied constructor seed, including an empty one, as the seeded session's first live write at the seq `firstLiveSeq` names. The event is the durable projection of that field: `firstLiveSeq` answers where this lifecycle's writes start for a consumer holding the object, while `session/end-seed` answers the same question for one holding only stored bytes. Its payload is empty — position and `time` carry the whole meaning — and it is not a `SurfaceEventType`, so it produces no message and cannot perturb derived history. The seq-0 marker distinguishes an empty resumed session from a genuinely fresh session, preventing new-session defaults from being applied during resume. A bracket owner reads it positionally: an unmatched opening marker before `session/end-seed` has a smaller seq, came from the constructor seed, and belongs to a lifecycle that has ended. Core writes the boundary and reads nothing from it; each bracket's vocabulary stays with its owning plugin, so no core predicate helper ships without a consumer to shape it. The constructor is the placement because it is the single waist every seeded session passes through. All six entry points reach it: `agents.resume()`, config-driven startup on a persisted id (`restoreOrCreateConfigured`), `sessions.fork()`, a subagent fork child, `coordinator.adopt()`'s live-prefix path, and a bare `sessions.create(id, {seed})`. A boundary written at persistence load would miss both fork paths — and a forked child inheriting a still-running parent's open `compact/start` is precisely the case that must be classifiable. A boundary written at loop start would miss `fork()` and `adopt()`, and would have to fire on `SessionStartSource: 'startup'`, which is what a fork child publishes, so that field would stop discriminating. -Two guards keep the marker from becoming noise. An empty seed writes nothing because there is no seed to end. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit. +Two guards keep the marker precise. An omitted seed writes nothing because the session is fresh. A seed already ending in one is not re-marked, which makes the write idempotent. Idempotence is load-bearing rather than tidiness — `agentFor()` resumes a cold session on first touch, so merely opening one in a client is a pickup, and without the guard browsing would grow a log by one event per visit. ## Persistence needs no changes @@ -48,7 +48,7 @@ The predicate holds for a bracket *this* session inherited, not as a liveness si Bought: one boundary, written in one place, correct for all six seeded-start paths — including the fork gap the persistence-layer version could not reach. The persistence packages keep a pure read path. `firstLiveSeq` gains a durable twin rather than a second, competing notion of the same boundary. -Cost: a seeded session's log is one event longer, which moved seq expectations in tests across nine packages (session, agent-loop, persistence contract, jsonl, session-query, session-title, subagent-inprocess, telemetry, token-meter). Two of those updates are load-bearing rather than mechanical: telemetry's adoption tests now assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is restated as "seed reproduced verbatim, plus one log-only boundary" with idempotence added as its own property. +Cost: a seeded session's log is one event longer, including an empty resumed log. Seq expectations move with that boundary. Two updates are load-bearing rather than mechanical: telemetry's adoption tests assert the boundary IS exported, because it is this lifecycle's own write, and the property suite's replay invariant is "seed reproduced verbatim, plus one log-only boundary" with idempotence as its own property. `session/end-seed` joins the on-disk vocabulary. Under the pre-release stance (`SESSION_FORMAT_VERSION` pinned at `0`, no compatibility promise) older logs simply lack it, and a log without a boundary correctly classifies nothing as constructor-seed history. diff --git a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md index 33680c1845..dca87e16de 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-session-end-seed-log-boundary.zh.md @@ -14,13 +14,13 @@ Status: implemented ## Decision -`Session` 的构造函数紧接非空构造种子之后追加仅日志事件 `session/end-seed`,作为带种子会话的第一次实时写入,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答本生命周期的写入从哪里开始,`session/end-seed` 则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。 +`Session` 的构造函数紧接显式传入的构造种子(包括空种子)之后追加仅日志事件 `session/end-seed`,作为带种子会话的第一次实时写入,位置正是 `firstLiveSeq` 指出的 seq。该事件是那个字段的持久投影:`firstLiveSeq` 为持有对象的消费方回答本生命周期的写入从哪里开始,`session/end-seed` 则为只持有存储字节的消费方回答同一问题。它的 payload 为空——位置与 `time` 承载全部含义——并且不是 `SurfaceEventType`,因此不产生消息,也无法扰动派生历史。这个 seq-0 标记把从空日志恢复的会话与真正的全新会话区分开来,从而防止恢复期间应用新会话默认值。 括号所有方按位置读取它:在 `session/end-seed` 之前的未配对开启标记具有更小的 seq,来自构造种子,并且属于一个已结束的生命周期。核心写入该边界但不从中读取任何内容;每个括号的词汇表仍归其所属插件,因此在没有消费方来塑形之前,核心不会先发布谓词辅助函数。 选择构造函数,是因为它是每一个带种子会话都必经的唯一收窄处。全部六个入口都会到达它:`agents.resume()`、在已持久化 id 上的配置驱动启动(`restoreOrCreateConfigured`)、`sessions.fork()`、子代理 fork 子会话、`coordinator.adopt()` 的实时前缀路径,以及裸的 `sessions.create(id, {seed})`。在持久化加载时写入的边界会漏掉两条 fork 路径——而一个继承了仍在运行的父会话开放 `compact/start` 的 fork 子会话,恰恰是必须可判定的场景。在 loop 启动时写入的边界会漏掉 `fork()` 与 `adopt()`,并且不得不在 `SessionStartSource: 'startup'` 上触发——那正是 fork 子会话发布的取值,于是该字段将不再具有区分力。 -两条守卫让这个标记不至于变成噪声。空种子不写入任何内容,因为没有种子需要结束。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。 +两条守卫让这个标记保持精确。省略种子时不写入任何内容,因为这是全新会话。种子本身已以该事件结尾时不会重复标记,这让写入具备幂等性。幂等性是承重的,而不是为了整洁——`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就是一次接手;没有这条守卫,浏览会让日志每访问一次就增长一个事件。 ## 持久化无需任何改动 @@ -48,7 +48,7 @@ Status: implemented 买到的:一条边界,在一处写入,对全部六条带种子启动路径都正确——包括持久化层方案触及不到的 fork 缺口。持久化各包保留纯读取路径。`firstLiveSeq` 获得一个持久孪生体,而不是关于同一边界的第二套彼此竞争的概念。 -代价:带种子会话的日志长了一个事件,这在九个包(session、agent-loop、持久化契约、jsonl、session-query、session-title、subagent-inprocess、telemetry、token-meter)里挪动了 seq 期望。其中两处更新是承重的而非机械的:telemetry 的收养测试现在断言该边界*会*被导出,因为它是本生命周期的自有写入;而属性测试套件的重放不变式被重述为"种子逐字节复现,外加一个仅日志边界",并把幂等性补成一条独立属性。 +代价:带种子会话的日志长了一个事件,空日志恢复也包括在内。seq 期望会随这条边界移动。两处更新是承重的而非机械的:telemetry 的收养测试断言该边界*会*被导出,因为它是本生命周期的自有写入;属性测试套件的回放不变式则是"种子逐字节复现,外加一个仅日志边界",并把幂等性作为独立属性。 `session/end-seed` 加入了落盘词汇表。在预发布立场下(`SESSION_FORMAT_VERSION` 固定为 `0`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地判定没有任何内容属于构造种子历史。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index ac37214ebf..fedfc7e489 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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-web-config-plane.md -2026-07-30-web-config-plane.md: 95ede6264026f7b32e95749d00fe841f57dbf867 -2026-07-30-web-config-plane.zh.md: 6e06b69218a405055621cbd40781f9fbda9f9e6b +2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633 +2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 95ede62640..6d1a8c242c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -20,7 +20,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently. -**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value and the wholesale `settings.replace` a removal needs can never drop a sibling's secret. An edit without removals lands as a minimal `settings.update` merge patch; clearing a fold field back to inherited or deleting a row replaces the whole user section, because merge semantics cannot express removal. +**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. ## Alternatives considered @@ -33,4 +33,4 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer ## Consequences -The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a `settings.replace` cannot re-supply a stored *literal* secret in the replaced subtree, which the reference-based default makes unreachable. +The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 6e06b69218..c3255cacfd 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -20,7 +20,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值,删除所需的整体 `settings.replace` 也绝不可能丢掉兄弟条目的机密。不含删除的编辑以一次最小的 `settings.update` 合并 patch 落地;把折叠区字段清回继承值或删除整行则经 `settings.replace` 替换整个用户分节,因为合并语义表达不了删除。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。 ## 曾考虑的替代方案 @@ -33,4 +33,4 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 ## 后果 -整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态与已配置态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及已记录在案的重置边界情形——`settings.replace` 无法在被替换的子树里重新补上已存储的*字面量*机密,而基于引用的默认形态让这种情况根本无从出现。 +整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 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-read-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml
new file mode 100644
index 0000000000..abefa88679
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-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-read-card.md
+2026-07-30-web-read-card.md: 1fb3d61a113d26f6daf023fc791f3638055b5be0
+2026-07-30-web-read-card.zh.md: 946bcca95bcc9bb50beb1ef22e77e4a21728b538
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card.md
new file mode 100644
index 0000000000..1fb3d61a11
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.md
@@ -0,0 +1,49 @@
+# Agent Note: Read card — the read tool's structured line window reaches the client
+
+Status: implemented
+
+English | [中文](2026-07-30-web-read-card.zh.md)
+
+## Problem
+
+The `read` tool returns a canonical output object `{ path, offset, lines: [{ number, text }], totalLines }`, but its presentation collapsed that structure. `presentCall` declared a `GenericCallView` (`kind: 'read'`, a follow-along location) and `presentResult` returned a `GenericResultView` whose only content was the model-facing text with its `file` envelope stripped. A UI receiving that view saw one flattened text block: the line numbers were baked into the text as `N: ` prefixes, the file's language was unknown, and `totalLines` was gone. There was no way for a capable client to render a read the way it renders a diff — a line-numbered, syntax-highlighted code view with the line-number gutter separate from the content.
+
+The structured data cannot be recovered downstream. A tool result on the wire carries only the model-facing `ContentBlock[]` (the rendered text) plus an opaque `meta`; the canonical output object stays in the tool and never reaches the client or the session log. So a client that wants the line array, the total, and a language hint cannot parse them back out of the `N: text` text — the tool has to project them onto a channel that persists.
+
+## Decision
+
+Add a fourth `card` tag, `read`, to the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) — result-side only. `ToolResultView` gains `ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }`; `ReadFileLine { number; text }` is the shared line unit. `ToolCallView` is untouched: the pending state stays a `GenericCallView` (`kind: 'read'`) because a call carries no file content until `execute` returns, so there is nothing structured to show at call time. This diverges from the bash terminal card, which tags both sides — a terminal call already carries its command and cwd at call time, a read call carries neither content nor total, so tagging the call side would add an empty variant.
+
+The read tool projects the structured window through `output.presentationMeta`, the same persisted channel write/edit use for their applied-diff hunks ([canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md)). `presentationMeta` runs once for a top-level surface call, returns `{ path, offset, lines, totalLines, lang? }` as JSON the session validates and stores on the result's `meta`, and `presentResult` narrows that meta back into the `ReadResultView` on both live and replay paths. `offset` (the 1-based first line the window requested) rides along because a byte cap below the first selected line yields an empty `lines` array with a positive `totalLines`; without the persisted `offset` a replayed card of such a window could not report where it starts or where a continuation resumes, and the last-line and re-parse fallbacks are both lossy. Without this channel the line array and total would be unreachable: the raw output object is not on the wire, and re-parsing the `N: text` text is lossy and fragile against the truncation footer.
+
+`presentResult` returns `undefined` — the generic fallback — whenever the meta is absent or malformed (`readMetaFromMeta` narrows it defensively, so a replay of an older logged result never throws), whenever the result is an error, and whenever the single text block is not the read envelope. A pre-card logged result — a valid read envelope with no persisted `meta`, recorded before this card existed — takes that same `undefined` path deliberately: the client falls back to the raw `result.content`, so it shows the enveloped `//` text rather than the envelope-stripped generic card the old presenter returned. This is the accepted degradation under the [pre-release stance](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius): reject the old on-disk format rather than add an envelope-stripping compatibility branch, since this PR re-records every published fixture and the session format promises no backward compatibility. On the success path `presentResult` carries `content` (the envelope-stripped text) alongside the structured fields, so a UI without the read capability, including the current TUI, renders the file text through the generic/default card arm exactly as before. The TUI's `renderBody` switch (`packages/ui/tui/src/components/transcript.ts`) is not `assertNever`-exhaustive: `terminal` and `diff` have arms and everything else falls through to the generic arm, which reads `view.content`. That default arm alone was not enough: `render()` sets `genericContent` — and with it the dim-Markdown `dimBody` treatment — on a separate gate that was `card === 'generic'` only, so a `read` card would have kept the text but lost its dim styling. The gate now admits `card: 'read'` too, taking `content` down the same dim-Markdown path, so a read renders in the TUI exactly as it did before the read card existed. Beyond that one gate the TUI needs no read-specific code.
+
+### Language hint derivation
+
+`langFromPath` (in `read-render.ts`) maps a file extension to a syntax-highlighting language id through a small fixed table (`LANG_BY_EXTENSION`) covering common source, config, and markup extensions. It reads the extension after the last path segment and last dot, is case-insensitive, and returns `undefined` for a dotfile (`.gitignore`), an extensionless name (`/etc/hosts`), a trailing dot, and any unknown extension — the card then omits `lang` and a UI renders plain text. The table is not a tunable: it is a display hint a UI may ignore, not a deployment-varying choice, and an unknown extension degrades to plain text rather than failing. It is deliberately small rather than an exhaustive language registry; extending it is a one-line table addition.
+
+## Alternatives considered
+
+**Re-parse the `N: text` model-facing text in `presentResult`.** Rejected: the structured line array would have to be reconstructed by splitting each line on the first `: `, which is ambiguous (a line whose own text contains `: `), loses the exact `totalLines` (the footer only states it in some branches), and breaks the moment the render format changes. `presentationMeta` carries the already-structured data with no re-parse.
+
+**Tag the call side too (`ReadCallView`), mirroring the terminal card's both-sides symmetry.** Rejected: a read call has no content, no line array, and no total until it executes — a call-side read card would be an empty variant duplicating what `GenericCallView` (`kind: 'read'`, follow-along location) already expresses. The terminal card tags both sides because a terminal call genuinely carries call-time data (command, cwd); a read call does not.
+
+**Put the structured window in a new service or a side channel instead of `meta`.** Rejected: `meta` is the established persisted presentation channel (write/edit's applied diffs ride it), it replays for free with the session log, and it needs no new plumbing. A service would reinvent persistence and replay that the event log already provides.
+
+**A merge-extensible union instead of a closed tag.** Rejected for the same reason the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) closed: a new card needs consuming code to render it, so a variant a consumer silently drops is worse than a compile error. Adding `read` to the closed union is the sanctioned way to extend it — each consumer that switches on `card` keeps compiling because the new member falls through its generic default, and a consumer that wants the rich view adds its own arm.
+
+## Consequences
+
+`ToolResultView` has a fourth member. Every consumer that switches on `card` keeps compiling: the TUI and the current Web client route an unknown card to their generic path, and the read card carries `content` so that path shows the file text. The Web frontend that renders the line-numbered, syntax-highlighted view from `lines`/`lang`/`totalLines` is a separate follow-up PR; this PR is the backend that makes the data reachable. Until that lands, a read renders exactly as it did before (the generic text card) everywhere.
+
+The read tool now computes `presentationMeta` for every top-level read, a small per-call projection (a `lines.map` and one `langFromPath` call) on data already in hand. The meta is persisted with the session log, so a read result is slightly larger on disk — the line array it already rendered as text, now also structured.
+
+## Testing
+
+`packages/fs/tool-fs/tests/read-render.spec.ts` unit-tests `langFromPath` (known extensions case-insensitively, extension read after the last segment and last dot, and the `undefined` cases: dotfile, extensionless, trailing dot, unknown) and `readMetaFromMeta` (a well-formed narrow with and without `lang`, and every rejection: non-object, array, missing or wrong-typed `path`/`totalLines`/`lines`, a malformed line entry, a non-string `lang`, and — because the function narrows the opaque persisted `meta` boundary — the semantically invalid paths a well-typed replayed JSON can still carry: an `offset` that is not a 1-based integer, a first line `number` below `offset`, a line `number` that is not a 1-based integer (`0`, `1.5`, `NaN`, `Infinity`), a `totalLines` that is not a non-negative integer (`-1`, `1.5`, `NaN`), and lines whose numbers duplicate, decrease, or exceed `totalLines`; it also narrows an empty window at a positive `offset` (a byte cap below the first selected line). `packages/fs/tool-fs/tests/tools.spec.ts` pins the tool wiring: `execute` attaches the structured window (with and without a `lang` hint) as `meta`, `presentResult` narrows it into a `card: 'read'` view carrying the envelope-stripped `content`, and the decline paths (error result, non-single-text content, malformed envelope with valid meta, and valid envelope with absent or malformed meta) all fall back to `undefined`. Both changed source files hold per-file 100% coverage. This PR carries the snapshot evidence for the persisted meta and the extended union, not for a new rendered view: the re-recorded ACP session fixtures (`fs-read`, `fs-read-window`, `fs-edit`, `fs-policy-reject`, `fs-write-overwrite`, `parallel-tool-calls`, `workspace-context`, `workspace-edit`) pin the persisted read `meta` (with `{{cwd}}`-tokenized paths), and `cordis-inspect-jsdoc` pins the four-member `ToolResultView` union. The keyless snapshot and assembled-application transcript for the rendered read card belong to the follow-up Web PR that consumes the view, since this PR adds no new product-user-visible rendering — the TUI routes the read card through its existing generic dim-Markdown fallback (`transcript.ts` treats `card: 'read'` like `card: 'generic'`), so its output is unchanged. The `apps/cli` `parallel-file-reads` terminal golden (`apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt`) pins exactly that: a real replay executes the read tool, renders it through the new `card: 'read'` gate, and the golden's dim-Markdown rows are byte-for-byte what a generic read produced before this card existed.
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `read` result arm.
+- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) — owns the `presentationMeta` persisted channel this projects the read window onto.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent for a client consuming a structured card; the read card follows the same producer pattern, result-side only.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
new file mode 100644
index 0000000000..946bcca95b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
@@ -0,0 +1,49 @@
+# Agent Note: Read card — the read tool's structured line window reaches the client
+
+Status: implemented
+
+[English](2026-07-30-web-read-card.md) | 中文
+
+## Problem
+
+`read` 工具返回规范化输出对象 `{ path, offset, lines: [{ number, text }], totalLines }`,但它的展示层把这个结构压平了。`presentCall` 声明为 `GenericCallView`(`kind: 'read'`,一个跟随定位),`presentResult` 返回 `GenericResultView`,其唯一内容是剥掉 `file` 信封后的面向模型文本。收到该视图的 UI 只看到一个压平的文本块:行号以 `N: ` 前缀烘焙进文本、文件语言未知、`totalLines` 丢失。capable 客户端无法像渲染 diff 那样渲染一次 read——即带行号、语法高亮、行号槽与内容分离的代码视图。
+
+结构化数据在下游无法恢复。线上(wire)的工具结果只携带面向模型的 `ContentBlock[]`(已渲染文本)加上一个不透明的 `meta`;规范化输出对象留在工具内,从不到达客户端或会话日志。因此想要行数组、总数和语言提示的客户端无法从 `N: text` 文本里解析回它们——工具必须把它们投影到一个会持久化的通道上。
+
+## Decision
+
+给[渲染意图 union](../architecture/2026-07-02-tool-render-intent-union.md) 新增第四个 `card` 标签 `read`——仅在结果侧。`ToolResultView` 增加 `ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }`;`ReadFileLine { number; text }` 是共享的行单元。`ToolCallView` 不动:待定状态仍是 `GenericCallView`(`kind: 'read'`),因为一次调用在 `execute` 返回前不携带文件内容,调用时没有可展示的结构。这与 bash 终端 card 不同——终端 card 两侧都打标签,因为终端调用在调用时已携带命令和 cwd,而 read 调用既无内容也无总数,给调用侧打标签只会新增一个空变体。
+
+read 工具通过 `output.presentationMeta` 投影结构化窗口,这与 write/edit 用来投影其应用 diff hunk 的持久化通道相同([规范化工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。`presentationMeta` 对一次顶层 surface 调用运行一次,返回 `{ path, offset, lines, totalLines, lang? }` 作为会话校验并存储在结果 `meta` 上的 JSON,`presentResult` 在 live 和回放路径上都把该 meta 收窄回 `ReadResultView`。`offset`(窗口请求的 1-based 起始行)一并携带,是因为当字节上限低于首个选中行时,窗口会返回空的 `lines` 数组而 `totalLines` 为正;没有持久化的 `offset`,这类窗口的回放 card 就无法报告它从哪行开始、或续读应从哪行继续,而末行推断与文本重解析两种兜底都有损。没有这个通道,行数组和总数就无法触及:原始输出对象不在线上,而重新解析 `N: text` 文本既有损又对截断脚注脆弱。
+
+`presentResult` 在以下情况返回 `undefined`——即 generic 回退:meta 缺失或畸形(`readMetaFromMeta` 防御性收窄它,因此回放旧的已记录结果永不抛错)、结果是错误、以及单个文本块不是 read 信封。本 card 出现之前记录的结果——信封合法但无持久化 `meta`——有意走同一条 `undefined` 路径:客户端回退到原始 `result.content`,因此显示带 `//` 信封的原文,而非旧展示器返回的剥信封 generic card。这是 [pre-release 立场](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius)下接受的降级:拒绝旧的磁盘格式,而非加一个剥信封的兼容分支——本 PR 已重录全部已发布 fixtures,且 session 格式不承诺向后兼容。在成功路径上,`presentResult` 在结构化字段之外携带 `content`(剥信封后的文本),因此不具备 read 能力的 UI(包括当前的 TUI)通过 generic/default card 分支渲染文件文本,与之前完全一致。TUI 的 `renderBody` switch(`packages/ui/tui/src/components/transcript.ts`)不是 `assertNever` 穷尽的:`terminal` 和 `diff` 有分支,其余都落入 generic 分支,该分支读取 `view.content`。仅有该默认分支还不够:`render()` 在一个独立门控上设置 `genericContent`(连同 dim-Markdown 的 `dimBody` 处理),该门控原先只判 `card === 'generic'`,因此 `read` card 虽保留文本却会丢失 dim 样式。现在该门控也接纳 `card: 'read'`,让 `content` 走同一条 dim-Markdown 路径,因此 read 在 TUI 中的渲染与 read card 出现之前完全一致。除这一处门控外,TUI 无需 read 专属代码。
+
+### 语言提示推导
+
+`langFromPath`(在 `read-render.ts` 中)通过一张固定小表(`LANG_BY_EXTENSION`,覆盖常见源码、配置、标记扩展名)把文件扩展名映射到语法高亮语言 id。它读取最后一个路径段与最后一个点之后的扩展名,大小写不敏感,并对以下情况返回 `undefined`:dotfile(`.gitignore`)、无扩展名(`/etc/hosts`)、结尾的点、以及任何未知扩展名——此时 card 省略 `lang`,UI 渲染纯文本。该表不是可调项(tunable):它是 UI 可忽略的展示提示,而非随部署变化的选择,未知扩展名降级为纯文本而非失败。它有意保持小规模而非穷尽的语言注册表;扩展它是一行表项新增。
+
+## Alternatives considered
+
+**在 `presentResult` 中重新解析 `N: text` 面向模型文本。** 已否决:结构化行数组将不得不通过按第一个 `: ` 切分每行来重建,这既有歧义(某行文本自身含 `: `),又丢失精确的 `totalLines`(脚注只在部分分支中陈述它),并在渲染格式变化时立即失效。`presentationMeta` 携带已经结构化的数据,无需重新解析。
+
+**调用侧也打标签(`ReadCallView`),镜像终端 card 的两侧对称。** 已否决:read 调用在执行前没有内容、没有行数组、没有总数——调用侧 read card 会是一个空变体,重复 `GenericCallView`(`kind: 'read'`,跟随定位)已经表达的东西。终端 card 两侧都打标签是因为终端调用确实携带调用时数据(命令、cwd);read 调用没有。
+
+**把结构化窗口放进新服务或旁路通道而非 `meta`。** 已否决:`meta` 是既有的持久化展示通道(write/edit 的应用 diff 就搭它),它随会话日志免费回放,无需新接线。服务会重新发明事件日志已提供的持久化与回放。
+
+**用 merge-extensible union 而非封闭标签。** 出于[渲染意图 union](../architecture/2026-07-02-tool-render-intent-union.md) 封闭的相同理由否决:新 card 需要消费代码来渲染它,因此被消费者静默丢弃的变体比编译错误更糟。把 `read` 加入封闭 union 是扩展它的许可方式——每个在 `card` 上 switch 的消费者都继续编译,因为新成员落入其 generic default,而想要富视图的消费者新增自己的分支。
+
+## Consequences
+
+`ToolResultView` 多了第四个成员。每个在 `card` 上 switch 的消费者都继续编译:TUI 和当前 Web 客户端把未知 card 路由到其 generic 路径,而 read card 携带 `content` 使该路径显示文件文本。从 `lines`/`lang`/`totalLines` 渲染带行号、语法高亮视图的 Web 前端是单独的后续 PR;本 PR 是让数据可触及的后端。在它落地前,read 在各处的渲染与之前完全一致(generic 文本 card)。
+
+read 工具现在为每次顶层 read 计算 `presentationMeta`,这是对已在手数据的一次小投影(一次 `lines.map` 和一次 `langFromPath` 调用)。meta 随会话日志持久化,因此 read 结果在磁盘上略大——它已渲染为文本的行数组,现在也以结构化形式存在。
+
+## Testing
+
+`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况:dotfile、无扩展名、结尾的点、未知)与 `readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、非字符串 `lang`,以及——因为该函数收窄持久化的 opaque `meta` 边界——良构类型的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的 `offset`、小于 `offset` 的首行 `number`、不是 1-based 整数的行 `number`(`0`、`1.5`、`NaN`、`Infinity`)、不是非负整数的 `totalLines`(`-1`、`1.5`、`NaN`)、以及行号重复、递减或超过 `totalLines` 的情况;并且收窄正 `offset` 处的空窗口(字节上限低于首个选中行))。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content` 的 `card: 'read'` 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。本 PR 携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP session fixtures(`fs-read`、`fs-read-window`、`fs-edit`、`fs-policy-reject`、`fs-write-overwrite`、`parallel-tool-calls`、`workspace-context`、`workspace-edit`)钉住持久化的读取 `meta`(含 `{{cwd}}` 令牌化路径),`cordis-inspect-jsdoc` 钉住四成员的 `ToolResultView` 联合类型。已渲染读取 card 的 keyless 快照与组装应用 transcript 属于消费该视图的后续 Web PR,因为本 PR 不新增任何面向产品用户可见的渲染——TUI 通过其现有的通用 dim-Markdown 回退路由读取 card(`transcript.ts` 把 `card: 'read'` 当作 `card: 'generic'` 处理),因此其输出保持不变。`apps/cli` 的 `parallel-file-reads` 终端 golden(`apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt`)正钉住这一点:一次真实回放执行 read 工具、经新的 `card: 'read'` 门渲染,golden 的 dim-Markdown 行与本 card 出现前 generic read 所产出的逐字节一致。
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 Note 以 `read` 结果分支扩展的 `card` 标签词汇。
+- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 拥有本 Note 用来投影 read 窗口的 `presentationMeta` 持久化通道。
+- [Web terminal card](2026-07-28-web-terminal-card.md) —— 客户端消费结构化 card 的先例;read card 遵循相同的生产者模式,仅结果侧。
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
index 1c2cbd0216..99ea335b59 100644
--- 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
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
-2026-07-31-gui-full-access-confirmation.md: 8208a20bee9b9ab8f1e73720790e3e5be4c27306
-2026-07-31-gui-full-access-confirmation.zh.md: 8ac115034f562fe96d53bdba010b05648d4d5947
+2026-07-31-gui-full-access-confirmation.md: ca89ed23fb1b5c6ea438dd22fdf20d2b82af754c
+2026-07-31-gui-full-access-confirmation.zh.md: 0f487e41b544718bdf38de611a1ab2d59c44b063
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
index 8208a20bee..ca89ed23fb 100644
--- 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
@@ -6,25 +6,26 @@ 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.
+Switching the web client to `danger-full-access` was a single click on a permission 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.**
+**Every permission picker gates `danger-full-access` behind the 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.
+- The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched.
+- `Full access` intentionally overrides the kebab-to-title display transform in every picker; command and Settings writes keep the machine name on the wire, and each 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.
+**One shared locale namespace for every surface's safety copy.** Rejected: the ui-permission bundle and ui-conversation load independently, while the Settings warning names a different future-session lifetime. Each bundle owns its copy, and ui-permission keeps the popup and Settings dictionaries separate rather than importing across bundle boundaries.
 
 **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.
+Every visible GUI path into Full access requires a deliberate, informed acknowledgement, at the cost of one extra dialog step for users who genuinely want the preset. New pickers reuse the shared dialog through their owning state machine or attach a `confirmation` payload to the popup path. Acceptance: the composer flow's gated cases in `input-bar.spec.tsx`, the popup gate in `popup-view.spec.tsx` and `popup.spec.ts`, the default-setting gate in `permission-row.spec.tsx`, the Modal/RiskConfirmation contract in `atoms.spec.tsx`, and the assembled Web replays.
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
index 8ac115034f..0f487e41b5 100644
--- 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
@@ -6,25 +6,26 @@ Status: implemented
 
 ## Problem
 
-Web 客户端切换到 `danger-full-access` 在两个权限面(编辑器的 Access chip 与 `/permission` popup 选择器)上都只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
+在 Web 客户端的权限选择器中切换到 `danger-full-access` 只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
 
 ## Decision
 
-**两个权限面都把 `danger-full-access` 关进同一个共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不提交任何命令。**
+**每个权限选择器都把 `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 感知。
+- 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。
+- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
 
 ## Alternatives considered
 
 **原生/操作系统或独立窗口确认。** 已拒:对话框必须留在当前 WebUI 窗口内;第二个窗口可能出现在另一块显示器上,使决策脱离其守护的页面状态。
 
-**两个面共享一个安全文案 locale namespace。** 已拒:ui-permission bundle 与 ui-conversation 可独立加载,故各自在自己的 namespace 下注册同一份文案(`permission.access` 与 conversation 词典并立);这处重复以带说明的 `jscpd:ignore` 块圈护,而非跨 bundle import。
+**每个面的安全文案共享一个 locale namespace。** 已拒:ui-permission bundle 与 ui-conversation 可独立加载,而 Settings 警示说明的是另一种只影响后续会话的生效周期。每个 bundle 各自拥有文案,ui-permission 也将 popup 与 Settings 词典分开,而非跨 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 钉住产品默认中文词典文案。
+进入 Full access 的每条可见 GUI 路径现在都要求刻意且知情的确认,代价是真想启用该预设的用户多一步对话框。新的选择器通过各自拥有的状态机复用共享对话框,或在 popup 路径挂 `confirmation` 载荷。验收:`input-bar.spec.tsx` 中编辑器流的门控用例、`popup-view.spec.tsx` 与 `popup.spec.ts` 的 popup 门、`permission-row.spec.tsx` 的默认设置门控、`atoms.spec.tsx` 的 Modal/RiskConfirmation 契约,以及组装态 Web 回放。
diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml
new file mode 100644
index 0000000000..8d4467858b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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-permission-default-for-new-sessions.md
+2026-07-31-permission-default-for-new-sessions.md: 35812b53d0c1448afd95b9a063eda6658fb1bef3
+2026-07-31-permission-default-for-new-sessions.zh.md: a75deaec323b57f2bc88e84dd4d5c7d7d98cd177
diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
new file mode 100644
index 0000000000..35812b53d0
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
@@ -0,0 +1,35 @@
+# Agent Note: Permission Settings default for new sessions
+
+Status: implemented
+
+English | [中文](2026-07-31-permission-default-for-new-sessions.zh.md)
+
+## Problem
+
+The Web General-settings page displayed Permission as a disabled skeleton even though `dsh-permission` already owned the preset table and current-session switch path. The Settings seam could persist a plugin-owned value, but the Web settings API exposed only configurable LLM-provider namespaces. More importantly, treating a user preference as a live global permission would make an existing session's execution policy change outside its durable log.
+
+## Decision
+
+`dsh-permission` owns a `permission` Settings namespace with one `defaultPreset` field. Its base value is `Config.defaultPreset`, or the preset matching the composed sandbox and approval defaults when the config omits it. The schema derives its enum from the configured preset table, so Settings validates stored values and the Web client discovers the deployment's actual choices without duplicating them.
+
+The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session.
+
+The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows.
+
+ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes emit `host/settings-changed` but not `host/models-changed`.
+
+## Consequences
+
+Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter the open session. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly.
+
+The assembled Web snapshot now contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `danger-full-access` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet.
+
+## Alternatives considered
+
+**Apply the Settings value live to every session.** Rejected because execution policy would change without a session event and replay could not reconstruct which permission governed an earlier tool call.
+
+**Record only `permission/preset` on creation.** Rejected because sandbox and approval are independently owned whole-value knobs; pinning all three facts keeps their consumers independent of future composition-default changes.
+
+**Expose all Settings registrations, or add a generic `local-client` declaration.** Rejected for this change because it expands a security boundary and the Settings contract beyond the one requested preference. The explicit `permission` allowlist entry is sufficient and leaves future namespaces to make their own exposure decision.
+
+**Apply the latest default while resuming a seeded session.** Rejected because resume must preserve the session's prior effective execution policy; missing legacy facts are materialized from that policy instead.
diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md
new file mode 100644
index 0000000000..a75deaec32
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 新会话的权限 Settings 默认值
+
+Status: implemented
+
+[English](2026-07-31-permission-default-for-new-sessions.md) | 中文
+
+## 问题
+
+Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 `dsh-permission` 已经拥有 preset 表和当前会话的切换路径。Settings seam 可以持久化由插件拥有的值,但 Web Settings API 只暴露可配置 LLM 提供方的 namespace。更重要的是,如果把用户偏好当成实时生效的全局权限,现有会话的执行策略就会在其持久日志之外发生变化。
+
+## 决策
+
+`dsh-permission` 拥有一个 `permission` Settings namespace,其中只有 `defaultPreset` 字段。它的基础值是 `Config.defaultPreset`;省略该配置时,则使用与组合后的沙箱和审批默认值匹配的 preset。schema 的 enum 从已配置的 preset 表派生,因此 Settings 既能校验已存储的值,Web 客户端也能发现部署中的实际选项,而无需重复定义。
+
+服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。
+
+现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。
+
+ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更会发出 `host/settings-changed`,但不会发出 `host/models-changed`。
+
+## 后果
+
+在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已打开的会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`。
+
+组装后的 Web 快照现在包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `danger-full-access` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。
+
+## 曾考虑的替代方案
+
+**将 Settings 值实时应用于每个会话。** 不予采纳,因为执行策略会在没有会话事件的情况下改变,重放也无法重建先前工具调用采用了哪种权限。
+
+**创建时只记录 `permission/preset`。** 不予采纳,因为沙箱和审批是由不同组件独立拥有的全量值调节项;固定全部三个事实,可以让其消费方不依赖未来的组合默认值变化。
+
+**暴露所有 Settings 注册,或增加通用的 `local-client` 声明。** 本次变更不予采纳,因为这会扩大安全边界,并使 Settings 契约超出所请求的单项偏好。显式加入 `permission` allowlist 已足够,未来的 namespace 可以各自决定是否暴露。
+
+**恢复带 seed 的会话时应用最新默认值。** 不予采纳,因为恢复操作必须保留会话先前的有效执行策略;缺失的旧版事实应从该策略中补齐。
diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml
new file mode 100644
index 0000000000..cca8f8a597
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.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-session-archive-global-set.md
+2026-07-31-session-archive-global-set.md: fab99a405a6f8264c36453473327e32905bac9c8
+2026-07-31-session-archive-global-set.zh.md: e33f3b5272a6d8fc90cfad247ba21d4d10afb045
diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md
new file mode 100644
index 0000000000..fab99a405a
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md
@@ -0,0 +1,33 @@
+# Agent Note: Session archive (registry-global set)
+
+Status: implemented
+
+English | [中文](2026-07-31-session-archive-global-set.zh.md)
+
+## Problem
+
+The session row menu in the sidebar workspace browser carried a purely visual "Delete session" placeholder (no handler). The product decision is **archive**, not delete: the session log and its workspace accounting stay untouched; the session merely disappears from every grouping surface (workspace groups, Ungrouped, search, the flat list). The archive record needs a home: an Ungrouped session belongs to no workspace entity, so a per-workspace field cannot carry it.
+
+## Decision
+
+**The archive set is a new field on the workspace domain's global singleton (`workspaceDomainState.archivedSessionIds`), layered over workspace accounting; display filtering converges entirely in the client's `tree.ts` derivation layer; the wire surface uses the full-snapshot posture.**
+
+- Storage: `archivedSessionIds: z.array(sessionId).default([])`, domain version stays 2 — a purely additive field; pre-field media parse to an empty set through the schema default, no migration code. An archived session keeps its `sessionIds` slot (a future unarchive restores its position), so the set never touches the one-owner accounting invariant.
+- Registry: `ctx.workspace.archiveSession(id)` rides `enqueueOperation`, serialized with create/delete; a session neither live nor persisted throws `WorkspaceUnknownSessionError`; an already archived id neither writes nor emits. The `archivedSessionIds` getter exposes the read-only set.
+- RPC: `workspace.archiveSession({sessionId}) → {archivedSessionIds}` (answers the full updated set); the `workspace.list` response carries the set as the reconnect baseline; a new host frame `host/archived-sessions-changed` pushes the full snapshot after every durable change (same posture as `host/workspace-changed`, emitted from the `domain/changed` global-put branch by set comparison). Unknown sessions reuse the `session-not-found` error code.
+- Client runtime: `WorkspaceListState.archivedSessionIds` (a `readonly SessionId[]` in Host order, reference replaced only on membership change — public snapshot state stays in the store engine's plain-data vocabulary since immer drafts reject Sets without the MapSet plugin; membership lookups build a transient Set in the derivation, the expandedProjects pattern); the list baseline, the unary echo, and the changed frame each install the complete set. the projection sweep clears the current selection whenever it lands in the archive set, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero) — one rule covering the local unary echo, another tab's changed frame, and a reconnect baseline restoring a selection archived while this client was away; a frame or echo landing during an in-flight `workspace.list` also shields the newer set from the stale baseline.
+- UI: the `delete` menu row (visual-only) becomes `archive` (label "Archive session", non-danger styling, no confirmation dialog — a non-destructive action whose worst misfire is list hiding); filtering is one extra arm in `tree.ts`'s `sessionVisible` predicate, with `deriveGroups`/`deriveFlat` taking an `archived` set parameter so all four surfaces (group loop, stray bucket, search, flat) share one source.
+
+## Alternatives considered
+
+**Per-workspace archivedSessionIds (the original phrasing).** Rejected: Ungrouped sessions have no home; the user switched to global.
+
+**An archived flag on SessionSummary (session.list layer).** Rejected: it joins a workspace-domain fact into the sessions-domain projection, summaries have no incremental frame so a separate notification would still be needed — cross-domain coupling outweighs the saving.
+
+**Host-side filtering in `workspaceView`/the `sessionIds` getter.** Rejected: archiving ≠ changing accounting, and filtering the projection muddles the two concepts; a future restore surface also needs the client to see full accounting.
+
+**Incremental frames (single archived/removed rows).** Rejected: the set is tiny and changes rarely; full snapshots spare the client merge logic and dedup state and match the existing workspace-changed posture.
+
+## Consequences
+
+Archived sessions have no viewing or unarchive surface yet (this iteration's scope; recorded as a README Known Limitation); data and accounting slots stay intact, so a future restore is one UI surface plus one inverse RPC. The `workspace.list` response shape change is a pre-release direct edit (no compatibility layer). The workspace-management e2e pins the full chain (archive → row disappears → still hidden after reload, log still present); domain tests pin idempotence, unknown-id rejection, restart recovery, and the pre-field media default upgrade.
diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md
new file mode 100644
index 0000000000..e33f3b5272
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: Session 归档(注册表级全局集合)
+
+状态:implemented
+
+[English](2026-07-31-session-archive-global-set.md) | 中文
+
+## 问题
+
+Sidebar workspace 浏览区的 session 行菜单里,「Delete session」一直是纯视觉占位(无 handler)。产品口径定为**归档**而非删除:session 日志与 workspace 记账都不动,只把该 session 从所有分组视图(workspace 分组、Ungrouped、搜索、平铺列表)里隐藏。归档记录需要一个落点:Ungrouped 的 session 不属于任何 workspace 实体,per-workspace 字段放不下它。
+
+## 决策
+
+**归档集合是 workspace domain 全局单例(`workspaceDomainState.archivedSessionIds`)上的一个新字段,覆盖在 workspace 记账之上;显示过滤全部收敛在 client 的 `tree.ts` 派生层;wire 面走全快照姿态。**
+
+- 存储:`archivedSessionIds: z.array(sessionId).default([])`,domain version 保持 2——纯增量字段,旧介质经 schema default 解析为空集合,无迁移代码。被归档的 session 保留其 `sessionIds` 席位(未来取消归档恢复原位置),因此与「一个 session 只被一个 workspace 记账」不变式零纠缠。
+- Registry:`ctx.workspace.archiveSession(id)` 走 `enqueueOperation` 与 create/delete 串行;未知 session(实时与持久化都查不到)抛 `WorkspaceUnknownSessionError`;已归档 id 不写盘不发事件。`archivedSessionIds` getter 暴露只读集合。
+- RPC:`workspace.archiveSession({sessionId}) → {archivedSessionIds}`(应答完整更新后集合);`workspace.list` 响应携带集合作为重连基线;新 host 帧 `host/archived-sessions-changed` 在每次持久变更后推完整快照(与 `host/workspace-changed` 同姿态,从 `domain/changed` 的 global put 分支比对推帧)。未知 session 复用错误码 `session-not-found`。
+- client runtime:`WorkspaceListState.archivedSessionIds`(按 Host 顺序的 `readonly SessionId[]`,成员不变不换引用——公有快照状态保持 store 引擎的纯数据词汇:immer draft 不开 MapSet 插件就不接受 Set;membership 查询在派生函数内自建临时 Set,与 expandedProjects 同款);list 基线、unary 回声、changed 帧三路都整体替换安装。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线恢复出一个离线期间被归档的 selection;帧/回声落在 in-flight `workspace.list` 期间时还会屏蔽旧基线对新集合的回滚。
+- UI:菜单项 `delete`(visual-only)改为 `archive`(label「Archive session」,非 danger 样式,无确认对话框——非破坏性操作,误触后果只是列表隐藏);过滤实现为 `tree.ts` 的 `sessionVisible` 判据加一档,`deriveGroups`/`deriveFlat` 增加 `archived` 集合入参,四个视图(分组循环、stray 兜底、搜索、平铺)同源生效。
+
+## 已考虑的替代方案
+
+**per-workspace archivedSessionIds(最初表述)。** 否决:Ungrouped session 无落点;用户改口全局。
+
+**SessionSummary 打 archived 标(session.list 层)。** 否决:要把 workspace domain 事实 join 进 sessions domain 投影,summary 无增量帧还得另发通知,跨域耦合大于收益。
+
+**host 侧在 `workspaceView`/`sessionIds` getter 过滤。** 否决:归档 ≠ 改记账,投影过滤会把两个概念搅浑;未来恢复入口也需要 client 拿到全量记账。
+
+**增量帧(archived/removed 单条)。** 否决:集合极小、变更频率低,全快照免去 client 侧合并逻辑与去重状态,与 workspace-changed 现有姿态一致。
+
+## 后果
+
+归档后 UI 无查看/取消归档入口(本期口径,README Known Limitation 记账);数据与席位完好,后续加恢复面只是 UI + 一个逆向 RPC。`workspace.list` 响应形状变化是 pre-release 直改(无兼容层)。e2e(workspace-management)钉住了「归档→行消失→reload 后仍隐藏、日志仍在」的全链路;domain 层测试钉住幂等、未知 id 拒绝、跨重启恢复与旧介质默认升级。
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index 90b27e84cb..6fa5aec1ab 100644
--- a/apps/web/tests/built-boot.snapshot.ts
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -111,6 +111,17 @@ 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
diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts
index 28c423b0a0..9c2215ed7f 100644
--- a/apps/web/tests/models-settings.e2e.ts
+++ b/apps/web/tests/models-settings.e2e.ts
@@ -1,14 +1,15 @@
 // Web e2e scenario: the Models settings page end to end through the real
 // wire — the add card offers the dormant pi-ai catalog, typing an API key
 // stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
-// while the settings document records only that reference, and the saved
-// route registers live (the row's 已启用 badge is the topology invalidation
-// landing). The customized-settings fold writes the curated reasoning field
-// as a merge patch. Zero model calls: configuration is pure
+// while the settings document records only that reference; the saved row
+// appears after the route topology invalidation without presenting liveness
+// as provider status. The customized-settings fold writes the curated
+// reasoning field as a merge patch. Zero model calls: configuration is pure
 // settings/credentials/llm-domain traffic, so there is no fixture and a
 // stray stream would fail loud on the open seam. The provider under test is
 // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
-// never shadow the derived reference.
+// never shadow the derived reference. Removing that row is guarded by the
+// localized provider-confirmation dialog before the unset reaches the wire.
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
@@ -24,6 +25,7 @@ import { saveFailureShot } from './support.ts'
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
 const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
 const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
+const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
 const MODE = webSnapshotMode()
 
 describe('web e2e: Models settings page configures a dormant provider', () => {
@@ -82,7 +84,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     // registers, and the topology frame invalidates the page into the row.
     const row = dialog.getByText('minimax-cn', { exact: true }).first()
     await row.waitFor({ timeout: 10_000 })
-    await dialog.getByText('已启用').waitFor({ timeout: 10_000 })
     const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(document).toContain('minimax-cn:')
     expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
@@ -109,11 +110,42 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
     const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('confirms provider deletion before removing its settings profile', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
+    const settingsDialog = page.getByRole('dialog', { name: '设置' })
+    await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
+    const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' })
+    await deleteDialog.waitFor({ timeout: 10_000 })
+    const snapshot = await captureStableAria(
+      page,
+      '[role="dialog"][aria-label="删除模型提供方?"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
+
+    await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
+    expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
+    await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
+    await page.getByRole('dialog', { name: '删除模型提供方?' })
+      .getByRole('button', { name: '删除提供方', exact: true }).click()
+    await expect.poll(
+      async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
+      { timeout: 10_000 },
+    ).not.toContain('minimax-cn:')
+    expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
+      .toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
+    await expect.poll(
+      async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(),
+      { timeout: 10_000 },
+    ).toBe(0)
     await page.keyboard.press('Escape')
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'empty.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md'])
   })
 })
diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts
index 775f1596df..a5aced7de2 100644
--- a/apps/web/tests/settings-chrome.e2e.ts
+++ b/apps/web/tests/settings-chrome.e2e.ts
@@ -2,15 +2,18 @@
 // section switching, both close paths), the Appearance preference row (the
 // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
 // -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
-// and the Language row (settings-scoped localization + persisted dsh.locale).
+// and the Language row (settings-scoped localization + persisted dsh.locale),
+// plus Permission as the persisted default for subsequently created sessions.
 // Zero model calls: everything is pure client + persistence state on a blank
 // frame, so there is no fixture and a stray stream would fail loud on the
 // open llm seam.
+import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { join } from 'node:path'
+import { SessionId } from '@deepseek-ai/dsh-session'
 import {
   acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
   launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
@@ -21,7 +24,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import
 const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
 const MODE = webSnapshotMode()
 
-describe('web e2e: settings modal, appearance gesture, language switch', () => {
+describe('web e2e: settings modal and General preferences', () => {
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
@@ -50,9 +53,9 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.waitFor({ timeout: 10_000 })
     expect(await trigger.getAttribute('aria-expanded')).toBe('true')
-    // General is the active section by default; its skeleton rows plus the
-    // functional Language and Appearance rows render.
+    // General is active by default; Permission, Language and Appearance are functional.
     expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
+    await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
     await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
     await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
     // Golden of the freshly opened dialog (default zh, General active).
@@ -73,6 +76,55 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
+  it('stores Permission as the default for future sessions without changing an existing session', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
+    const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
+    expect(existing.events.find(event => event.type === 'permission/preset')?.data)
+      .toEqual({ preset: 'danger-full-access' })
+
+    await page.getByRole('button', { name: '设置', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    await dialog.waitFor({ timeout: 10_000 })
+    const selector = dialog.getByRole('button', { name: 'Full access' })
+    await selector.waitFor({ timeout: 10_000 })
+    await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
+    await selector.click()
+    await page.getByRole('menuitem', { name: 'Read Only' }).click()
+    await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
+
+    const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    expect(document).toContain('permission:')
+    expect(document).toContain('defaultPreset: read-only')
+    expect(existing.events.find(event => event.type === 'permission/preset')?.data)
+      .toEqual({ preset: 'danger-full-access' })
+
+    const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
+    expect(created.events.map(event => [event.type, event.data])).toEqual([
+      ['permission/preset', { preset: 'read-only' }],
+      ['sandbox/mode', { mode: 'read-only' }],
+      ['approval/policy', { policy: 'ask' }],
+    ])
+
+    await dialog.getByRole('button', { name: 'Read Only' }).click()
+    await page.getByRole('menuitem', { name: 'Full access' }).click()
+    const confirmation = page.getByRole('dialog', { name: '确认启用 Full access?' })
+    const enable = confirmation.getByRole('button', { name: '启用 Full access' })
+    expect(await enable.isDisabled()).toBe(true)
+    await confirmation.getByRole('checkbox').click()
+    await enable.click()
+    await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
+    const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
+    const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
+    expect(confirmed.events.map(event => [event.type, event.data])).toEqual([
+      ['permission/preset', { preset: 'danger-full-access' }],
+      ['sandbox/mode', { mode: 'danger-full-access' }],
+      ['approval/policy', { policy: 'never' }],
+    ])
+    await page.keyboard.press('Escape')
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it('flips the theme through the Appearance cubes and persists across reload', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
     const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
index 7024719a3a..dc4cbca241 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
@@ -16,7 +16,7 @@
   - treeitem "workspace 1 session" [expanded]:
     - img
     - text: workspace 1 session
-  - treeitem "New Session now" [selected]
+  - treeitem "New Session" [selected]
 - button "Settings":
   - img
   - text: Settings
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
index 15bee7afe4..7f62e80503 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
@@ -16,7 +16,7 @@
   - treeitem "workspace 1 session" [expanded]:
     - img
     - text: workspace 1 session
-  - treeitem "New Session now" [selected]
+  - treeitem "New Session" [selected]
 - button "Settings":
   - img
   - text: Settings
diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md
index 8b9c4ad6e1..251352ee00 100644
--- a/apps/web/tests/snapshots/models-settings/configured.expected.md
+++ b/apps/web/tests/snapshots/models-settings/configured.expected.md
@@ -14,7 +14,7 @@
   - paragraph: 填入各提供方的 API 密钥即可使用其模型。
   - list:
     - listitem:
-      - text: minimax-cn 已启用
+      - text: minimax-cn
       - button "编辑"
       - button "删除"
   - button "+ 添加提供方"
diff --git a/apps/web/tests/snapshots/models-settings/delete.expected.md b/apps/web/tests/snapshots/models-settings/delete.expected.md
new file mode 100644
index 0000000000..afb0cb5fd2
--- /dev/null
+++ b/apps/web/tests/snapshots/models-settings/delete.expected.md
@@ -0,0 +1,7 @@
+- dialog "删除模型提供方?":
+  - heading "删除模型提供方?" [level=2]
+  - button "关闭":
+    - img
+  - paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。
+  - button "取消"
+  - button "删除提供方"
diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md
index 75959994f1..e782d25c05 100644
--- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md
+++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md
@@ -10,11 +10,11 @@
   - button "关闭":
     - img
     - text: 关闭
-  - text: 权限 选择默认权限模式
-  - button "Read only" [disabled]:
-    - text: Read only
+  - text: 权限 选择新会话的默认权限模式
+  - button "Full access":
+    - text: Full access
     - img
-  - text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言
+  - text: 语言
   - button "中文":
     - text: 中文
     - img
diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts
index e6a7f31f18..48483e512d 100644
--- a/apps/web/tests/workspace-management.e2e.ts
+++ b/apps/web/tests/workspace-management.e2e.ts
@@ -1,10 +1,13 @@
 // Web e2e scenarios: workspace management — the create-by-name dialog, the
 // rename round trip over the real wire (workspace.rename RPC + durable
 // registry), duplicate-name pre-check, the flat "In one list" view with its
-// persisted group-by preference, and the session hover card. Zero model
-// calls: workspace.create/rename are host RPCs with no model involvement,
-// and the one session row the flat/hover scenarios need comes from a seeded
-// fixture (the seeded-history seed reused verbatim — no new recording).
+// persisted group-by preference, the session hover card, and the session
+// archive round trip (row menu → workspace.archiveSession RPC → durable
+// global set → row hidden across reload). Zero model calls:
+// workspace.create/rename/archiveSession are host RPCs with no model
+// involvement, and the one session row the flat/hover/archive scenarios need
+// comes from a seeded fixture (the seeded-history seed reused verbatim — no
+// new recording).
 import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
@@ -413,6 +416,55 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
+  it('archives the seeded session from its row menu, hiding it durably across reload', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive'))
+    // The seeded session lives under Ungrouped (expanded by the hover-card
+    // test's gesture; converge again for order independence).
+    const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
+    const ungroupedSection = ungroupedRow.locator('..')
+    await expect.poll(async () => {
+      if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
+        await page.getByText('Ungrouped', { exact: true }).click()
+        await page.waitForTimeout(50)
+      }
+      return await ungroupedRow.getAttribute('aria-expanded')
+    }, { timeout: 5_000 }).toBe('true')
+    // Anchor on session rows (the rows carrying a session actions button),
+    // not a positional index, and assert the single-stray assumption loudly
+    // so a fixture gaining a second stray fails here instead of archiving
+    // the wrong row. CSS attribute match, not getByRole: the button is
+    // display:none until its row hovers, and role queries skip hidden nodes.
+    const sessionRows = ungroupedSection.locator('[role="treeitem"]')
+      .filter({ has: page.locator('button[aria-label^="Session actions for "]') })
+    await expect.poll(() => sessionRows.count(), { timeout: 10_000 }).toBe(1)
+    const sessionRow = sessionRows.first()
+    const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
+    // Row menu: hover reveals the actions button; Archive session commits
+    // without a confirmation dialog (non-destructive: log + accounting stay).
+    await sessionRow.hover()
+    await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click()
+    await page.getByRole('menuitem', { name: 'Archive session' }).click()
+    // The row disappears on the archive-set echo; with no other visible
+    // stray, the whole Ungrouped bucket withdraws.
+    await expect.poll(() => page.getByText(rowTitle, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
+    await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
+    // Durable on the host: the registry-global set carries the id while the
+    // session log itself stays in persistence untouched.
+    expect([...scaffold.ctx.workspace.archivedSessionIds]).toEqual([SessionId(SEED_ID)])
+    expect((await scaffold.ctx.sessionPersistence.list()).map(header => header.id)).toContain(SessionId(SEED_ID))
+    // Reload: the hidden state is rebuilt from the workspace.list baseline.
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+    // The archived row must not resurface (the Ungrouped bucket itself may
+    // reappear if selection restore lands on another stray — not this test's
+    // concern).
+    expect(await page.getByText(rowTitle, { exact: true }).count()).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 90_000)
+
   it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
     expect(tripwire.warnings).toEqual([])
     // The directory-browser aria golden is this spec's one owned artifact;
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index c64676690d..68a0d707cb 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -871,10 +871,10 @@ Source: [`packages/mcp/mcp-client/src/index.ts:93`](../packages/mcp/mcp-client/s
 
 ## `@deepseek-ai/dsh-permission`
 
-Requires: `bash` · `approval`
+Requires: `bash` · `approval` · `sessions`
 
 ```ts config-catalog
-/** The {@link PermissionService} config: the deployment's preset table. */
+/** The {@link PermissionService} config: preset table and composition default. */
 export interface Config {
   /**
    * The preset table: name → knob bundle. Defaults to `workspace-write`
@@ -882,6 +882,11 @@ export interface Config {
    * never). The name `custom` is reserved for the derived not-a-preset state.
    */
   presets?: Record
+  /**
+   * Default for new sessions. When omitted, the preset matching the composed
+   * sandbox and approval defaults is used.
+   */
+  defaultPreset?: string
 }
 
 /** One preset's sandbox/approval bundle and optional client presentation. */
@@ -899,7 +904,7 @@ export interface PresetSpec {
 
 Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMode`](core-data-structures/sandbox.md)
 
-Source: [`packages/ui/permission/src/index.ts:130`](../packages/ui/permission/src/index.ts)
+Source: [`packages/ui/permission/src/index.ts:140`](../packages/ui/permission/src/index.ts)
 
 ## `@deepseek-ai/dsh-plan-mode`
 
@@ -1989,7 +1994,7 @@ export interface Config {
 export type ToolPresentationMode = 'native' | 'code' | 'both'
 ```
 
-Source: [`packages/core/tools/src/index.ts:587`](../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:589`](../packages/core/tools/src/index.ts)
 
 ## `@deepseek-ai/dsh-tui`
 
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index ec868fe8ce..c9d4ad3e5b 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -938,7 +938,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
 'tools/change'(): void
 ```
 
-Source: [`packages/core/tools/src/index.ts:165`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:167`](../../packages/core/tools/src/index.ts)
 
 ### `tools/code-dispatch-log` — waterfall
 
@@ -962,7 +962,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri
 
 Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:147`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts)
 
 ### `tools/execute` — waterfall
 
@@ -984,7 +984,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
 
 Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:122`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:124`](../../packages/core/tools/src/index.ts)
 
 ### `tools/post-execute` — waterfall
 
@@ -1007,7 +1007,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
 
 Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:134`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:136`](../../packages/core/tools/src/index.ts)
 
 ### `tools/pre-execute` — waterfall
 
@@ -1028,7 +1028,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
 
 Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:111`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts)
 
 ### `tools/result` — emit
 
@@ -1047,7 +1047,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
 
 Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:155`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:157`](../../packages/core/tools/src/index.ts)
 
 ## `workflow/*`
 
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 3fa28af4cf..d92f467469 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -934,7 +934,7 @@ set(session: Session, name: string): void
 
 Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
 
-Source: [`packages/ui/permission/src/index.ts:144`](../../packages/ui/permission/src/index.ts)
+Source: [`packages/ui/permission/src/index.ts:159`](../../packages/ui/permission/src/index.ts)
 
 ## `ctx.planMode` — `PlanModeService`
 
@@ -1652,7 +1652,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
 
 Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/index.ts:739`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:741`](../../packages/core/session/src/index.ts)
 
 ## `ctx.sessionTitle` — `SessionTitleService`
 
@@ -2313,7 +2313,7 @@ async execute(exec: ToolExecutionInput): Promise
 
 Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:709`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:711`](../../packages/core/tools/src/index.ts)
 
 ## `ctx.tui` — `TuiExtensionService` (abstract seam)
 
@@ -2544,6 +2544,15 @@ list(): Workspace[]
  */
 delete(id: WorkspaceId): Promise
 
+/**
+ * Archive one session durably. The session must exist (live or in session
+ * persistence); its workspace accounting — or lack of one — is irrelevant.
+ * An already archived id resolves without writing.
+ * @param sessionId - The session to archive.
+ * @returns resolution after durability.
+ */
+archiveSession(sessionId: SessionId): Promise
+
 /**
  * Resolve by canonical directory path without creating or mutating a
  * workspace. A missing path rejects during `realpath`; an existing unowned
@@ -2554,7 +2563,9 @@ delete(id: WorkspaceId): Promise
 async resolveByPath(path: string): Promise
 ```
 
-Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts)
+Types: [SessionId](../core-data-structures/core.md)
+
+Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts)
 
 ## Inherited `ctx` members (cordis core + loader/hmr/timer)
 
diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml
index 04ad28d9b5..05398d5e7f 100644
--- a/docs/core-data-structures/session.i18n.yaml
+++ b/docs/core-data-structures/session.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 docs/core-data-structures/session.md
-session.md: e70add64198efd57538d1a014f24533d5197e531
-session.zh.md: d83cba6fbcb4ffcf137203d5e3e55045f1444a8b
+session.md: f337a6ffb200ffbe8146ee168b1aa0c4030defe0
+session.zh.md: 1637efedbacd76cfd651badbaf699655ad8e94fb
diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md
index e70add6419..f337a6ffb2 100644
--- a/docs/core-data-structures/session.md
+++ b/docs/core-data-structures/session.md
@@ -94,7 +94,9 @@ interface SessionEventMap {
   /**
    * Marks the end of a constructor seed. Events before it have smaller seq
    * values and came from the seed (resume, fork, or replay); this lifecycle
-   * produced none of them. This log-only event is the durable projection of
+   * produced none of them. An explicitly supplied empty seed puts the marker
+   * at seq 0, distinguishing an empty resumed session from a fresh session.
+   * This log-only event is the durable projection of
    * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
    * carry the meaning.
    *
@@ -352,7 +354,9 @@ declare class Session {
    * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
    * boundary: a resumed session's constructor seed is its full stored log,
    * while its header keeps the original fork value — this field is the
-   * in-process construction fact.
+   * in-process construction fact. An explicitly supplied empty seed has the
+   * same value as no seed (0); its `session/end-seed` event preserves the
+   * lifecycle distinction.
    *
    * Not persisted itself: a seeded session projects it into the log as the
    * `session/end-seed` event, which is what a consumer reading STORED history
@@ -548,7 +552,7 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c
 
 A seeded session — resume, fork, or replay — appends this log-only event immediately after its constructor seed, as its first live write. Events before it have smaller seq values and came from the seed. It is the durable projection of `firstLiveSeq`: that field answers where this lifecycle's writes start for a consumer holding the object, while the event answers the same question for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer.
 
-An empty seed writes nothing, and a seed already ending in `session/end-seed` is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the LAST `session/end-seed` in stored history rather than assuming one exists at `firstLiveSeq`: after a pickup with no work, the event has a smaller seq than the next lifecycle's `firstLiveSeq`.
+An explicitly supplied empty seed writes `session/end-seed` at seq 0, which distinguishes an empty resumed session from a fresh one. A seed already ending in `session/end-seed` is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the LAST `session/end-seed` in stored history rather than assuming one exists at `firstLiveSeq`: after a pickup with no work, the event has a smaller seq than the next lifecycle's `firstLiveSeq`.
 
 It exists because seed history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker before `session/end-seed` came from the constructor seed and belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`.
 
diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md
index d83cba6fbc..1637efedba 100644
--- a/docs/core-data-structures/session.zh.md
+++ b/docs/core-data-structures/session.zh.md
@@ -94,7 +94,9 @@ interface SessionEventMap {
   /**
    * Marks the end of a constructor seed. Events before it have smaller seq
    * values and came from the seed (resume, fork, or replay); this lifecycle
-   * produced none of them. This log-only event is the durable projection of
+   * produced none of them. An explicitly supplied empty seed puts the marker
+   * at seq 0, distinguishing an empty resumed session from a fresh session.
+   * This log-only event is the durable projection of
    * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
    * carry the meaning.
    *
@@ -354,7 +356,9 @@ declare class Session {
    * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
    * boundary: a resumed session's constructor seed is its full stored log,
    * while its header keeps the original fork value — this field is the
-   * in-process construction fact.
+   * in-process construction fact. An explicitly supplied empty seed has the
+   * same value as no seed (0); its `session/end-seed` event preserves the
+   * lifecycle distinction.
    *
    * Not persisted itself: a seeded session projects it into the log as the
    * `session/end-seed` event, which is what a consumer reading STORED history
@@ -552,7 +556,7 @@ interface TurnEndReasonMap {
 
 带种子的会话(恢复、fork 或回放)紧接构造种子之后追加这个仅日志事件,作为自己的第一次实时写入。在它之前的事件具有更小的 seq,且来自种子。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答本生命周期的写入从哪里开始,该事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。
 
-空种子不写入任何内容;种子本身已以 `session/end-seed` 结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。应定位存储历史中的最后一条 `session/end-seed`,而不是假定 `firstLiveSeq` 处一定有一条:在一次没有产生工作的拾起之后,该事件的 seq 会小于下一个生命周期的 `firstLiveSeq`。
+显式传入的空种子会在 seq 0 写入 `session/end-seed`,从而把从空日志恢复的会话与全新会话区分开来。种子本身已以 `session/end-seed` 结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。应定位存储历史中的最后一条 `session/end-seed`,而不是假定 `firstLiveSeq` 处一定有一条:在一次没有产生工作的拾起之后,该事件的 seq 会小于下一个生命周期的 `firstLiveSeq`。
 
 它之所以必要,是因为种子历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。在 `session/end-seed` 之前的开启标记来自构造种子,并且属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。
 
diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml
index 3cdeab29cb..156324472b 100644
--- a/docs/core-data-structures/tools.i18n.yaml
+++ b/docs/core-data-structures/tools.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 docs/core-data-structures/tools.md
-tools.md: a595a038dc3770557c7207fe2603067190de9662
-tools.zh.md: 18052e2fc7907ce0ef051099149e5e70feae0676
+tools.md: 98b642b846b23e2b29e6c6d800fe4106235eda85
+tools.zh.md: 1ef90c1e76ace7485ed6267de5ee82cbb4de6aa6
diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md
index a595a038dc..98b642b846 100644
--- a/docs/core-data-structures/tools.md
+++ b/docs/core-data-structures/tools.md
@@ -447,8 +447,8 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }
 How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on:
 
 - `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file).
-- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search → grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob); `truncated`/`total` report whether the inline result was capped so a UI never presents a partial result as complete; the view carries no result text — a UI without a search card falls back to the raw result content), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet; a search and a web retrieval have no `card` call-time analogue (their pending state stays a generic card, since the structured result exists only after `execute`).
+- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search → grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob); `truncated`/`total` report whether the inline result was capped so a UI never presents a partial result as complete; the view carries no result text — a UI without a search card falls back to the raw result content), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lang` is a language hint from the extension, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet; a search and a web retrieval have no `card` call-time analogue (their pending state stays a generic card, since the structured result exists only after `execute`).
 
-`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`) and `FileDiff` (`{ path, oldText, newText }`) are the shared file-card vocabulary. The design is pinned in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); the TUI and host/client runtime project this neutral vocabulary into their own views.
+`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`), `FileDiff` (`{ path, oldText, newText }`), and `ReadFileLine` (`{ number, text }`, one 1-based numbered line of a read window) are the shared file-card vocabulary. The design is pinned in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); the TUI and host/client runtime project this neutral vocabulary into their own views.
 
 The full presentation field docs live in [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts). The `bash` schema and executor are on [bash.md](bash.md); generic background controls are on [tasks.md](tasks.md).
diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md
index 18052e2fc7..1ef90c1e76 100644
--- a/docs/core-data-structures/tools.zh.md
+++ b/docs/core-data-structures/tools.zh.md
@@ -447,8 +447,8 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }
 工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI(命令行界面)日志行),提供方无关,使工具在不依赖任何客户端协议的情况下描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发:
 
 - `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。
-- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索→`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表;`truncated`/`total` 报告内联结果是否被截断,使 UI 永不把部分结果当作完整结果呈现;该视图不携带结果文本——无 search 卡片的 UI 回退到原始结果内容)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 UI 回退到原始结果内容——正文不会重复进视图)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果;搜索和 web 检索都没有 `card` 的调用时对应视图(其 pending 状态保持为 generic 卡片,因为结构化结果只在 `execute` 之后才存在)。
+- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索→`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表;`truncated`/`total` 报告内联结果是否被截断,使 UI 永不把部分结果当作完整结果呈现;该视图不携带结果文本——无 search 卡片的 UI 回退到原始结果内容)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lang` 是从扩展名推得的语言提示,`content` 是无读取能力的 UI 回退时使用的去信封文本)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 UI 回退到原始结果内容——正文不会重复进视图)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果;搜索和 web 检索都没有 `card` 的调用时对应视图(其 pending 状态保持为 generic 卡片,因为结构化结果只在 `execute` 之后才存在)。
 
-`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)与 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;TUI 和 host/client 运行时将这套中性词汇投影为各自的视图。
+`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)、`FileDiff`(`{ path, oldText, newText }`)与 `ReadFileLine`(`{ number, text }`,读取窗口中一行带 1-based 行号的内容)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;TUI 和 host/client 运行时将这套中性词汇投影为各自的视图。
 
 完整的展示字段文档见 [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts)。`bash` schema 与执行器见 [bash.md](bash.md);通用后台控制见 [tasks.md](tasks.md)。
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 1d319a6699..f220c0ac6b 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -34,7 +34,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:59`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
@@ -48,12 +48,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
-| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
-| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:147`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
-| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:122`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
-| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:134`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:111`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
-| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:155`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
+| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
+| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
+| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:124`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
+| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:136`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
+| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:157`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
@@ -66,14 +66,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
 | `commands/changed` | `runtime` (`emit`) | `ui-command` |
-| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models` |
+| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission` |
 | `credentials/changed` | `runtime` (`emit`) | `ui-models` |
 | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
 | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` |
 | `internal/status` | - | [`agent`](../packages/core/agent) |
 | `locale/change` | `locale` (`emit`) | `locale` |
 | `models/changed` | `runtime` (`emit`) | `ui-models` |
-| `settings/changed` | `runtime` (`emit`) | `ui-models` |
+| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission` |
 | `slash/input-begin-command` | - | `ui-conversation` |
 | `slash/input-consume-token` | - | `ui-conversation` |
 | `slash/input-insert-reference` | - | `ui-conversation` |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 45eaddf9e9..1a75d881e7 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -652,6 +652,7 @@ flowchart TD
   pkg_permission --> pkg_sandbox_policy
   pkg_permission --> pkg_session
   pkg_permission --> pkg_session_projection
+  pkg_permission --> pkg_settings
   pkg_permission --> pkg_user_approval
   pkg_client_ui_goal --> pkg_client_connection
   pkg_client_ui_goal --> pkg_client_locale
@@ -822,10 +823,14 @@ 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_connection
   pkg_client_ui_permission --> pkg_client_locale
   pkg_client_ui_permission --> pkg_client_runtime
+  pkg_client_ui_permission --> pkg_client_schema_form
   pkg_client_ui_permission --> pkg_client_ui_command
+  pkg_client_ui_permission --> pkg_client_ui_primitives
   pkg_client_ui_permission --> pkg_client_ui_slash
+  pkg_client_ui_permission --> pkg_client_ui_slots
   pkg_client_ui_permission --> pkg_invariants
   pkg_client_ui_permission --> pkg_permission
   pkg_session_reference --> pkg_agent
@@ -1147,7 +1152,7 @@ flowchart TD
 | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
 | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
-| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
+| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) |
 | [`client-ui-goal`](../packages/client/ui-goal) | `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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
 | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
@@ -1175,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-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) |
+| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`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) |
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 4d4faa42e3..2b36c0c3ff 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -78,7 +78,7 @@ export type SessionEvent = {
 }[T]
 ```
 
-Sources: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:318`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts)
 
 ## Events
 
@@ -350,7 +350,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src
 'permission/preset': { preset: string }
 ```
 
-Source: [`packages/ui/permission/src/index.ts:49`](../packages/ui/permission/src/index.ts)
+Source: [`packages/ui/permission/src/index.ts:50`](../packages/ui/permission/src/index.ts)
 
 ### `plan/*`
 
@@ -410,7 +410,9 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
 /**
  * Marks the end of a constructor seed. Events before it have smaller seq
  * values and came from the seed (resume, fork, or replay); this lifecycle
- * produced none of them. This log-only event is the durable projection of
+ * produced none of them. An explicitly supplied empty seed puts the marker
+ * at seq 0, distinguishing an empty resumed session from a fresh session.
+ * This log-only event is the durable projection of
  * {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
  * carry the meaning.
  *
@@ -432,7 +434,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
 'session/end-seed': Record
 ```
 
-Source: [`packages/core/session/src/types.ts:278`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/types.ts)
 
 #### `session/title` — log-only
 
diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
index 7db4157bb7..495a490774 100644
--- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
@@ -11,7 +11,7 @@
 {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
 {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}}
-{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n    /**\n     * Register globally or in the calling agent scope. Scoped tools shadow\n     * globals; duplicates within one layer and the reserved `run_code` name fail.\n     * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n     * @returns the exact disposer that unregisters the tool.\n     */\n    register(definition: ToolDefinition): () => void\n    /**\n     * Restrict global tools for the calling agent scope. Empty filters, unknown\n     * names, scope-local names, and reserved transport names fail. Restrictions\n     * intersect; scoped registrations remain visible.\n     * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n     * @returns the exact disposer that lifts this restriction.\n     */\n    restrict(filter: ToolRestriction): () => void\n    /**\n     * Register a monotonic guard after the extensible `tools/pre-execute`\n     * waterfall. A plain-context guard applies globally; one registered through\n     * `agent.ctx` applies only to that agent. Any matching guard may deny by\n     * returning a reason, while no guard can force-allow a call another guard\n     * denied. The exact effect disposer is returned for ordered ownership and\n     * HMR cleanup.\n     * @param guard - synchronous check; a returned string denies the execution.\n     * @returns the exact disposer that unregisters the guard.\n     */\n    guard(guard: ToolGuard): () => void\n    /**\n     * Look up a tool as one scope sees it (scoped\n     * shadows global; a restricted-away global reads as absent). Presenters pass\n     * the calling agent so the rendered card matches the definition that\n     * actually executed.\n     * @param name - the tool name as registered.\n     * @param scope - the viewing scope (the agent); omitted = the global view.\n     * @returns the definition the scope resolves, or undefined when none is visible.\n     */\n    get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n    /**\n     * Project visible definitions onto the allowlisted model-facing schema fields,\n     * excluding execution and presentation callbacks.\n     * @param scope - the viewing scope (the agent); omitted = the global view.\n     * @returns one deep-cloned schema per visible tool.\n     */\n    schemas(scope?: ScopeKey): ToolSchema[]\n    /**\n     * Classify a pending call through the caller's visible tool definition. Only\n     * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n     * throwing classifiers are exclusive.\n     * @param exec - call name, parsed arguments, and optional agent scope.\n     * @returns the fail-closed scheduling mode.\n     */\n    executionMode(exec: ToolExecutionInput): ToolExecutionMode\n    /**\n     * Execute through pre-policy, guards, around-dispatch, post-policy,\n     * definition-owned content finalization, and final notification. Tool and\n     * listener failures resolve as materialized error results; an invisible tool\n     * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n     * snapshot final observers receive. Cancellation\n     * arriving after entry and before final result materialization skips a\n     * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n     * successful started outcome with `ABORTED`; already-started work is still\n     * drained and may retain a tool-owned structured error.\n     * @param exec - the typed same-process call input. The registry assigns its\n     *   correlation token before policy begins.\n     * @returns the materialized final result.\n     */\n    async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n    export interface Agent {\n        readonly id: SessionId;\n        readonly options: AgentOptions;\n        readonly session: Session;\n        readonly status: AgentStatus;\n        readonly acceptsNextStep: boolean;\n        readonly ctx: Context;\n        send(message: UserMessage, options: SendOptions): void;\n        updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n        cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n        whenIdle(): Promise;\n        followup(message: UserMessage): void;\n        steer(message: UserMessage): void;\n        inject(message: UserMessage): void;\n    }\n    export type AgentCancelCause = {\n        readonly kind: 'user';\n    } | {\n        readonly kind: 'parent';\n    };\n    export interface AgentOptions {\n        provider?: string;\n        model?: string;\n        maxTokens?: number;\n    }\n    export type AgentStatus = 'idle' | 'running';\n    export interface AssistantMessage extends Message {\n        readonly role: 'assistant';\n        readonly source: ModelMessageSource;\n    }\n    export interface AssistantProvenance {\n        provider: string;\n        model: string;\n        replayState?: unknown;\n    }\n    export type Branded = string & {\n        readonly [BRAND]: B;\n    };\n    export type CallId = Branded<'CallId'>;\n    export interface CancelOptions {\n        keepInbox?: boolean;\n    }\n    export interface ContentBlockMap {\n        'text': TextBlock;\n        'reasoning': ReasoningBlock;\n        'tool-call': ToolCallBlock;\n        'tool-result': ToolResultBlock;\n    }\n    export type ContentBlockType = keyof ContentBlockMap;\n    export interface DiffCallView {\n        card: 'diff';\n        title: string;\n        diffs: FileDiff[];\n        locations?: FileLocation[];\n    }\n    export interface DiffResultView {\n        card: 'diff';\n        title?: string;\n        diffs: FileDiff[];\n    }\n    export interface EpochHeader {\n        config: LlmCallConfig;\n        adapterDefaults?: LlmCallConfigAdapterDefaults;\n        system?: string;\n        tools?: ToolSchema[];\n    }\n    export interface FileDiff {\n        path: string;\n        oldText: string | null;\n        newText: string;\n    }\n    export interface FileLocation {\n        path: string;\n        line?: number;\n    }\n    export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n    export interface FinishReasonMap {\n        'stop': {\n            kind: 'stop';\n        };\n        'tool-calls': {\n            kind: 'tool-calls';\n        };\n        'max-tokens': {\n            kind: 'max-tokens';\n        };\n        'aborted': {\n            kind: 'aborted';\n            failure: LlmFailure;\n        };\n        'error': {\n            kind: 'error';\n            failure: LlmFailure;\n        };\n    }\n    export interface GenericCallView {\n        card: 'generic';\n        title: string;\n        kind?: ToolCallKind;\n        rawInput?: unknown;\n        content?: ContentBlock[];\n        locations?: FileLocation[];\n    }\n    export interface GenericResultView {\n        card: 'generic';\n        title?: string;\n        content?: ContentBlock[];\n    }\n    export type InboxAction = {\n        readonly kind: 'edit';\n        readonly content: ContentBlock[];\n    } | {\n        readonly kind: 'remove';\n    };\n    export type InboxActionResult = 'applied' | 'not-found';\n    export type InboxItemId = Branded<'InboxItemId'>;\n    export interface JsonSchemaNode {\n        type?: JsonSchemaType;\n        oneOf?: JsonSchemaNode[];\n        properties?: Record;\n        required?: string[];\n        additionalProperties?: boolean;\n        items?: JsonSchemaNode;\n        enum?: JsonSchemaScalar[];\n        const?: JsonSchemaScalar;\n        description?: string;\n        title?: string;\n        default?: JsonValue;\n        examples?: JsonValue;\n    }\n    export type JsonSchemaScalar = string | number | boolean | null;\n    export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n    export type JsonValue = null | boolean | number | string | JsonValue[] | {\n        [key: string]: JsonValue;\n    };\n    export interface LlmCallConfig {\n        provider: string;\n        model: string;\n        reasoningEffort?: ReasoningEffortId;\n        temperature?: number;\n        maxTokens?: number;\n        stop?: string[];\n    }\n    export interface LlmCallConfigAdapterDefaults {\n        reasoningEffort?: true;\n        maxTokens?: true;\n    }\n    export interface LlmFailure {\n        readonly message: string;\n        readonly code: string;\n        readonly status?: number;\n        readonly providerRetryAfterMs?: number;\n        readonly requestId?: ProviderRequestId;\n    }\n    export interface Message {\n        readonly id: MessageId;\n        readonly role: 'system' | 'user' | 'assistant';\n        readonly content: ContentBlock[];\n        readonly source: MessageSource;\n    }\n    export type MessageId = Branded<'MessageId'>;\n    export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n    export interface MessageSourceMap {\n        user: {\n            kind: 'user';\n        };\n        plugin: {\n            kind: 'plugin';\n            plugin: string;\n        };\n        model: ModelMessageSource;\n        tool: ToolMessageSource;\n    }\n    export interface ModelMessageSource extends AssistantProvenance {\n        kind: 'model';\n    }\n    export type ProviderRequestId = Branded<'ProviderRequestId'>;\n    export interface ReasoningBlock {\n        type: 'reasoning';\n        text: string;\n    }\n    export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n    export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n    export type ScopeKey = object;\n    export interface SearchFileMatches {\n        path: string;\n        matches: SearchLineMatch[];\n    }\n    export interface SearchLineMatch {\n        lineNumber: number;\n        line: string;\n    }\n    export interface SearchMatchesResultView {\n        card: 'search';\n        shape: 'matches';\n        title?: string;\n        files: SearchFileMatches[];\n        truncated: boolean;\n        total: number;\n    }\n    export interface SearchPathsResultView {\n        card: 'search';\n        shape: 'paths';\n        title?: string;\n        paths: string[];\n        truncated: boolean;\n        total: number;\n    }\n    export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n    export interface SendOptions {\n        target: SendTarget;\n        wakeup: boolean;\n    }\n    export type SendTarget = 'next-turn' | 'next-step';\n    export class Session {\n        get surface(): SessionSurface;\n        readonly header: SessionHeader;\n        get id(): SessionId;\n        readonly firstLiveSeq: number;\n        constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n        get events(): readonly SessionEvent[];\n        get seq(): number;\n        append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n            opts: SurfaceIntent\n        ] : [\n        ]): SessionEvent;\n        requestHeader(): EpochHeader | undefined;\n        deriveMessages(): Message[];\n        deriveEventMessage(event: SessionEvent): Message | null;\n    }\n    export type SessionEvent = {\n        [K in SessionEventType]: {\n            type: K;\n            seq: number;\n            time: number;\n            data: SessionEventMap[K];\n        } & (K extends SurfaceEventType ? {\n            sourceEventSeqs?: number[];\n            surfaceOp?: SurfaceOp;\n        } : object);\n    }[T];\n    export interface SessionEventMap {\n        'turn/start': {\n            turn: number;\n            trigger: TurnTrigger;\n        };\n        'turn/end': {\n            turn: number;\n            reason: TurnEndReason;\n        };\n        'step/start': {\n            turn: number;\n            step: number;\n        };\n        'step/end': {\n            turn: number;\n            step: number;\n        };\n        'user/message': UserMessage;\n        'assistant/chunk': {\n            turn: number;\n            step: number;\n            chunk: StreamChunk;\n        };\n        'assistant/message': {\n            turn: number;\n            step: number;\n            message: AssistantMessage;\n            usage?: TokenUsage;\n        };\n        'tool/call': {\n            turn: number;\n            step: number;\n            callId: CallId;\n            name: string;\n            arguments: string;\n        };\n        'tool/result': {\n            turn: number;\n            step: number;\n            message: ToolResultMessage;\n            error?: {\n                name: string;\n                code: string;\n            };\n            meta?: JsonValue;\n        };\n        'steering/message': {\n            turn: number;\n            message: UserMessage;\n        };\n        'todo/write': {\n            todos: TodoItem[];\n        };\n        'request/header': {\n            header: EpochHeader;\n            reason: RequestHeaderReason;\n        };\n        'session/end-seed': Record;\n    }\n    export type SessionEventType = keyof SessionEventMap;\n    export interface SessionHeader {\n        readonly version: number;\n        readonly id: SessionId;\n        readonly createdAt: number;\n        readonly cwd?: string;\n        readonly parentSession?: SessionId;\n        readonly seedLength?: number;\n        readonly delegationDepth?: number;\n    }\n    export type SessionId = Branded<'SessionId'>;\n    export interface SessionSurface {\n        readonly nodes: readonly number[];\n        readonly replaceGeneration: number;\n    }\n    export type StreamChunk = {\n        type: 'block-start';\n        index: number;\n        blockType: ContentBlockType;\n    } | {\n        type: 'text-delta';\n        index: number;\n        text: string;\n    } | {\n        type: 'reasoning-delta';\n        index: number;\n        text: string;\n    } | {\n        type: 'tool-call-delta';\n        index: number;\n        id: CallId;\n        name?: string;\n        argumentsDelta: string;\n    } | {\n        type: 'block-end';\n        index: number;\n        block: ContentBlock;\n    } | {\n        type: 'usage';\n        usage: TokenUsage;\n    } | {\n        type: 'finish';\n        reason: FinishReason;\n        replayState?: unknown;\n    };\n    export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n    export interface SurfaceIntent {\n        surfaceOp: SurfaceOp;\n        sourceEventSeqs?: number[];\n    }\n    export type SurfaceOp = 'append' | {\n        op: 'replace';\n        start: number;\n        end: number;\n    };\n    export interface TerminalCallView {\n        card: 'terminal';\n        title: string;\n        description?: string;\n        cwd?: string;\n    }\n    export interface TerminalResultView {\n        card: 'terminal';\n        title?: string;\n        output?: string;\n        exitCode?: number;\n        signal?: string;\n    }\n    export interface TodoItem {\n        content: string;\n        status: 'pending' | 'in_progress' | 'completed';\n    }\n    export interface TokenUsage {\n        inputTokens: number;\n        outputTokens: number;\n        cacheReadTokens?: number;\n        cacheWriteTokens?: number;\n        reasoningTokens?: number;\n    }\n    export interface ToolCallBlock {\n        type: 'tool-call';\n        id: CallId;\n        name: string;\n        arguments: string;\n    }\n    export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n    export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n    export interface ToolDefinition extends ToolSchema {\n        readonly output: ToolOutputDefinition;\n        execute(args: unknown, exec: ToolRunContext): Promise;\n        finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n        timeoutMs?: number;\n        isConcurrencySafe?(args: unknown): boolean;\n        presentCall?(args: unknown): ToolCallView | undefined;\n        presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n    }\n    export interface ToolErrorInfo {\n        name: string;\n        code: string;\n    }\n    export interface ToolExecution extends ToolExecutionInput {\n        readonly token: ToolExecutionToken;\n    }\n    export interface ToolExecutionFailure {\n        readonly isError: true;\n        readonly error: ToolFailure;\n        readonly value?: never;\n        readonly content: ContentBlock[];\n        readonly meta?: JsonValue;\n        readonly additionalContexts?: UserMessage[];\n        readonly concludesTurn?: never;\n    }\n    export interface ToolExecutionInput {\n        readonly callId: CallId;\n        readonly name: string;\n        readonly arguments: unknown;\n        readonly agent?: Agent;\n        readonly parent?: ToolExecutionToken;\n        readonly signal: AbortSignal;\n    }\n    export type ToolExecutionMode = {\n        kind: 'parallel';\n    } | {\n        kind: 'exclusive';\n    };\n    export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n    export interface ToolExecutionSuccess {\n        readonly isError: false;\n        readonly value: JsonValue;\n        readonly content: ContentBlock[];\n        readonly error?: never;\n        readonly meta?: JsonValue;\n        readonly additionalContexts?: UserMessage[];\n        readonly concludesTurn?: true;\n    }\n    export type ToolExecutionToken = symbol & {\n        readonly [toolExecutionTokenBrand]: true;\n    };\n    export interface ToolFailure {\n        message: string;\n        info?: ToolErrorInfo;\n    }\n    export type ToolGuard = (execution: Readonly) => string | undefined;\n    export interface ToolMessageSource {\n        kind: 'tool';\n        callId: CallId;\n    }\n    export interface ToolOutputDefinition {\n        readonly schema: JsonSchemaNode;\n        render(args: unknown, value: JsonValue): ContentBlock[];\n        presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n    }\n    export interface ToolRestriction {\n        readonly allow?: readonly string[];\n        readonly deny?: readonly string[];\n    }\n    export interface ToolResult {\n        content: ContentBlock[];\n        isError: boolean;\n        meta?: JsonValue;\n    }\n    export interface ToolResultBlock {\n        type: 'tool-result';\n        toolCallId: CallId;\n        content: ContentBlock[];\n        isError?: boolean;\n    }\n    export interface ToolResultMessage extends Message {\n        readonly role: 'user';\n        readonly content: [\n            ToolResultBlock\n        ];\n        readonly source: ToolMessageSource;\n    }\n    export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | WebResultView;\n    export interface ToolRunContext extends ToolExecution {\n        deferContext(context: UserMessage): void;\n        concludeTurn(): void;\n    }\n    export interface ToolSchema {\n        name: string;\n        description: string;\n        parameters: Record;\n    }\n    export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n    export interface TurnEndReasonMap {\n        completed: {\n            kind: 'completed';\n        };\n        aborted: {\n            kind: 'aborted';\n        };\n        error: {\n            kind: 'error';\n            step: number;\n        } & ({\n            failure: LlmFailure;\n            message?: never;\n            code?: never;\n        } | {\n            message: string;\n            code?: string;\n            failure?: never;\n        });\n        disposed: {\n            kind: 'disposed';\n        };\n        'max-tokens': {\n            kind: 'max-tokens';\n        };\n        interrupted: {\n            kind: 'interrupted';\n        };\n    }\n    export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n    export interface TurnTriggerMap {\n        message: {\n            kind: 'message';\n            source: MessageSource;\n        };\n        retry: {\n            kind: 'retry';\n        };\n        injection: {\n            kind: 'injection';\n            source: MessageSource;\n        };\n    }\n    export interface UserMessage extends Message {\n        readonly role: 'user';\n    }\n    export interface WebFetchResultView {\n        card: 'web';\n        kind: 'fetch';\n        title?: string;\n        url: string;\n        statusCode: number;\n        truncated: boolean;\n    }\n    export type WebResultView = WebSearchResultView | WebFetchResultView;\n    export interface WebSearchResultView {\n        card: 'web';\n        kind: 'search';\n        title?: string;\n        sources: WebSource[];\n        answer?: string;\n        truncated: boolean;\n    }\n    export interface WebSource {\n        url: string;\n        title?: string;\n        snippet?: string;\n        publishedAt?: string;\n    }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
+{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n    /**\n     * Register globally or in the calling agent scope. Scoped tools shadow\n     * globals; duplicates within one layer and the reserved `run_code` name fail.\n     * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n     * @returns the exact disposer that unregisters the tool.\n     */\n    register(definition: ToolDefinition): () => void\n    /**\n     * Restrict global tools for the calling agent scope. Empty filters, unknown\n     * names, scope-local names, and reserved transport names fail. Restrictions\n     * intersect; scoped registrations remain visible.\n     * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n     * @returns the exact disposer that lifts this restriction.\n     */\n    restrict(filter: ToolRestriction): () => void\n    /**\n     * Register a monotonic guard after the extensible `tools/pre-execute`\n     * waterfall. A plain-context guard applies globally; one registered through\n     * `agent.ctx` applies only to that agent. Any matching guard may deny by\n     * returning a reason, while no guard can force-allow a call another guard\n     * denied. The exact effect disposer is returned for ordered ownership and\n     * HMR cleanup.\n     * @param guard - synchronous check; a returned string denies the execution.\n     * @returns the exact disposer that unregisters the guard.\n     */\n    guard(guard: ToolGuard): () => void\n    /**\n     * Look up a tool as one scope sees it (scoped\n     * shadows global; a restricted-away global reads as absent). Presenters pass\n     * the calling agent so the rendered card matches the definition that\n     * actually executed.\n     * @param name - the tool name as registered.\n     * @param scope - the viewing scope (the agent); omitted = the global view.\n     * @returns the definition the scope resolves, or undefined when none is visible.\n     */\n    get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n    /**\n     * Project visible definitions onto the allowlisted model-facing schema fields,\n     * excluding execution and presentation callbacks.\n     * @param scope - the viewing scope (the agent); omitted = the global view.\n     * @returns one deep-cloned schema per visible tool.\n     */\n    schemas(scope?: ScopeKey): ToolSchema[]\n    /**\n     * Classify a pending call through the caller's visible tool definition. Only\n     * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n     * throwing classifiers are exclusive.\n     * @param exec - call name, parsed arguments, and optional agent scope.\n     * @returns the fail-closed scheduling mode.\n     */\n    executionMode(exec: ToolExecutionInput): ToolExecutionMode\n    /**\n     * Execute through pre-policy, guards, around-dispatch, post-policy,\n     * definition-owned content finalization, and final notification. Tool and\n     * listener failures resolve as materialized error results; an invisible tool\n     * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n     * snapshot final observers receive. Cancellation\n     * arriving after entry and before final result materialization skips a\n     * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n     * successful started outcome with `ABORTED`; already-started work is still\n     * drained and may retain a tool-owned structured error.\n     * @param exec - the typed same-process call input. The registry assigns its\n     *   correlation token before policy begins.\n     * @returns the materialized final result.\n     */\n    async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n    export interface Agent {\n        readonly id: SessionId;\n        readonly options: AgentOptions;\n        readonly session: Session;\n        readonly status: AgentStatus;\n        readonly acceptsNextStep: boolean;\n        readonly ctx: Context;\n        send(message: UserMessage, options: SendOptions): void;\n        updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n        cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n        whenIdle(): Promise;\n        followup(message: UserMessage): void;\n        steer(message: UserMessage): void;\n        inject(message: UserMessage): void;\n    }\n    export type AgentCancelCause = {\n        readonly kind: 'user';\n    } | {\n        readonly kind: 'parent';\n    };\n    export interface AgentOptions {\n        provider?: string;\n        model?: string;\n        maxTokens?: number;\n    }\n    export type AgentStatus = 'idle' | 'running';\n    export interface AssistantMessage extends Message {\n        readonly role: 'assistant';\n        readonly source: ModelMessageSource;\n    }\n    export interface AssistantProvenance {\n        provider: string;\n        model: string;\n        replayState?: unknown;\n    }\n    export type Branded = string & {\n        readonly [BRAND]: B;\n    };\n    export type CallId = Branded<'CallId'>;\n    export interface CancelOptions {\n        keepInbox?: boolean;\n    }\n    export interface ContentBlockMap {\n        'text': TextBlock;\n        'reasoning': ReasoningBlock;\n        'tool-call': ToolCallBlock;\n        'tool-result': ToolResultBlock;\n    }\n    export type ContentBlockType = keyof ContentBlockMap;\n    export interface DiffCallView {\n        card: 'diff';\n        title: string;\n        diffs: FileDiff[];\n        locations?: FileLocation[];\n    }\n    export interface DiffResultView {\n        card: 'diff';\n        title?: string;\n        diffs: FileDiff[];\n    }\n    export interface EpochHeader {\n        config: LlmCallConfig;\n        adapterDefaults?: LlmCallConfigAdapterDefaults;\n        system?: string;\n        tools?: ToolSchema[];\n    }\n    export interface FileDiff {\n        path: string;\n        oldText: string | null;\n        newText: string;\n    }\n    export interface FileLocation {\n        path: string;\n        line?: number;\n    }\n    export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n    export interface FinishReasonMap {\n        'stop': {\n            kind: 'stop';\n        };\n        'tool-calls': {\n            kind: 'tool-calls';\n        };\n        'max-tokens': {\n            kind: 'max-tokens';\n        };\n        'aborted': {\n            kind: 'aborted';\n            failure: LlmFailure;\n        };\n        'error': {\n            kind: 'error';\n            failure: LlmFailure;\n        };\n    }\n    export interface GenericCallView {\n        card: 'generic';\n        title: string;\n        kind?: ToolCallKind;\n        rawInput?: unknown;\n        content?: ContentBlock[];\n        locations?: FileLocation[];\n    }\n    export interface GenericResultView {\n        card: 'generic';\n        title?: string;\n        content?: ContentBlock[];\n    }\n    export type InboxAction = {\n        readonly kind: 'edit';\n        readonly content: ContentBlock[];\n    } | {\n        readonly kind: 'remove';\n    };\n    export type InboxActionResult = 'applied' | 'not-found';\n    export type InboxItemId = Branded<'InboxItemId'>;\n    export interface JsonSchemaNode {\n        type?: JsonSchemaType;\n        oneOf?: JsonSchemaNode[];\n        properties?: Record;\n        required?: string[];\n        additionalProperties?: boolean;\n        items?: JsonSchemaNode;\n        enum?: JsonSchemaScalar[];\n        const?: JsonSchemaScalar;\n        description?: string;\n        title?: string;\n        default?: JsonValue;\n        examples?: JsonValue;\n    }\n    export type JsonSchemaScalar = string | number | boolean | null;\n    export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n    export type JsonValue = null | boolean | number | string | JsonValue[] | {\n        [key: string]: JsonValue;\n    };\n    export interface LlmCallConfig {\n        provider: string;\n        model: string;\n        reasoningEffort?: ReasoningEffortId;\n        temperature?: number;\n        maxTokens?: number;\n        stop?: string[];\n    }\n    export interface LlmCallConfigAdapterDefaults {\n        reasoningEffort?: true;\n        maxTokens?: true;\n    }\n    export interface LlmFailure {\n        readonly message: string;\n        readonly code: string;\n        readonly status?: number;\n        readonly providerRetryAfterMs?: number;\n        readonly requestId?: ProviderRequestId;\n    }\n    export interface Message {\n        readonly id: MessageId;\n        readonly role: 'system' | 'user' | 'assistant';\n        readonly content: ContentBlock[];\n        readonly source: MessageSource;\n    }\n    export type MessageId = Branded<'MessageId'>;\n    export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n    export interface MessageSourceMap {\n        user: {\n            kind: 'user';\n        };\n        plugin: {\n            kind: 'plugin';\n            plugin: string;\n        };\n        model: ModelMessageSource;\n        tool: ToolMessageSource;\n    }\n    export interface ModelMessageSource extends AssistantProvenance {\n        kind: 'model';\n    }\n    export type ProviderRequestId = Branded<'ProviderRequestId'>;\n    export interface ReadFileLine {\n        number: number;\n        text: string;\n    }\n    export interface ReadResultView {\n        card: 'read';\n        title?: string;\n        path: string;\n        offset: number;\n        lines: ReadFileLine[];\n        totalLines: number;\n        lang?: string;\n        content?: ContentBlock[];\n    }\n    export interface ReasoningBlock {\n        type: 'reasoning';\n        text: string;\n    }\n    export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n    export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n    export type ScopeKey = object;\n    export interface SearchFileMatches {\n        path: string;\n        matches: SearchLineMatch[];\n    }\n    export interface SearchLineMatch {\n        lineNumber: number;\n        line: string;\n    }\n    export interface SearchMatchesResultView {\n        card: 'search';\n        shape: 'matches';\n        title?: string;\n        files: SearchFileMatches[];\n        truncated: boolean;\n        total: number;\n    }\n    export interface SearchPathsResultView {\n        card: 'search';\n        shape: 'paths';\n        title?: string;\n        paths: string[];\n        truncated: boolean;\n        total: number;\n    }\n    export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n    export interface SendOptions {\n        target: SendTarget;\n        wakeup: boolean;\n    }\n    export type SendTarget = 'next-turn' | 'next-step';\n    export class Session {\n        get surface(): SessionSurface;\n        readonly header: SessionHeader;\n        get id(): SessionId;\n        readonly firstLiveSeq: number;\n        constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n        get events(): readonly SessionEvent[];\n        get seq(): number;\n        append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n            opts: SurfaceIntent\n        ] : [\n        ]): SessionEvent;\n        requestHeader(): EpochHeader | undefined;\n        deriveMessages(): Message[];\n        deriveEventMessage(event: SessionEvent): Message | null;\n    }\n    export type SessionEvent = {\n        [K in SessionEventType]: {\n            type: K;\n            seq: number;\n            time: number;\n            data: SessionEventMap[K];\n        } & (K extends SurfaceEventType ? {\n            sourceEventSeqs?: number[];\n            surfaceOp?: SurfaceOp;\n        } : object);\n    }[T];\n    export interface SessionEventMap {\n        'turn/start': {\n            turn: number;\n            trigger: TurnTrigger;\n        };\n        'turn/end': {\n            turn: number;\n            reason: TurnEndReason;\n        };\n        'step/start': {\n            turn: number;\n            step: number;\n        };\n        'step/end': {\n            turn: number;\n            step: number;\n        };\n        'user/message': UserMessage;\n        'assistant/chunk': {\n            turn: number;\n            step: number;\n            chunk: StreamChunk;\n        };\n        'assistant/message': {\n            turn: number;\n            step: number;\n            message: AssistantMessage;\n            usage?: TokenUsage;\n        };\n        'tool/call': {\n            turn: number;\n            step: number;\n            callId: CallId;\n            name: string;\n            arguments: string;\n        };\n        'tool/result': {\n            turn: number;\n            step: number;\n            message: ToolResultMessage;\n            error?: {\n                name: string;\n                code: string;\n            };\n            meta?: JsonValue;\n        };\n        'steering/message': {\n            turn: number;\n            message: UserMessage;\n        };\n        'todo/write': {\n            todos: TodoItem[];\n        };\n        'request/header': {\n            header: EpochHeader;\n            reason: RequestHeaderReason;\n        };\n        'session/end-seed': Record;\n    }\n    export type SessionEventType = keyof SessionEventMap;\n    export interface SessionHeader {\n        readonly version: number;\n        readonly id: SessionId;\n        readonly createdAt: number;\n        readonly cwd?: string;\n        readonly parentSession?: SessionId;\n        readonly seedLength?: number;\n        readonly delegationDepth?: number;\n    }\n    export type SessionId = Branded<'SessionId'>;\n    export interface SessionSurface {\n        readonly nodes: readonly number[];\n        readonly replaceGeneration: number;\n    }\n    export type StreamChunk = {\n        type: 'block-start';\n        index: number;\n        blockType: ContentBlockType;\n    } | {\n        type: 'text-delta';\n        index: number;\n        text: string;\n    } | {\n        type: 'reasoning-delta';\n        index: number;\n        text: string;\n    } | {\n        type: 'tool-call-delta';\n        index: number;\n        id: CallId;\n        name?: string;\n        argumentsDelta: string;\n    } | {\n        type: 'block-end';\n        index: number;\n        block: ContentBlock;\n    } | {\n        type: 'usage';\n        usage: TokenUsage;\n    } | {\n        type: 'finish';\n        reason: FinishReason;\n        replayState?: unknown;\n    };\n    export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n    export interface SurfaceIntent {\n        surfaceOp: SurfaceOp;\n        sourceEventSeqs?: number[];\n    }\n    export type SurfaceOp = 'append' | {\n        op: 'replace';\n        start: number;\n        end: number;\n    };\n    export interface TerminalCallView {\n        card: 'terminal';\n        title: string;\n        description?: string;\n        cwd?: string;\n    }\n    export interface TerminalResultView {\n        card: 'terminal';\n        title?: string;\n        output?: string;\n        exitCode?: number;\n        signal?: string;\n    }\n    export interface TodoItem {\n        content: string;\n        status: 'pending' | 'in_progress' | 'completed';\n    }\n    export interface TokenUsage {\n        inputTokens: number;\n        outputTokens: number;\n        cacheReadTokens?: number;\n        cacheWriteTokens?: number;\n        reasoningTokens?: number;\n    }\n    export interface ToolCallBlock {\n        type: 'tool-call';\n        id: CallId;\n        name: string;\n        arguments: string;\n    }\n    export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n    export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n    export interface ToolDefinition extends ToolSchema {\n        readonly output: ToolOutputDefinition;\n        execute(args: unknown, exec: ToolRunContext): Promise;\n        finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n        timeoutMs?: number;\n        isConcurrencySafe?(args: unknown): boolean;\n        presentCall?(args: unknown): ToolCallView | undefined;\n        presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n    }\n    export interface ToolErrorInfo {\n        name: string;\n        code: string;\n    }\n    export interface ToolExecution extends ToolExecutionInput {\n        readonly token: ToolExecutionToken;\n    }\n    export interface ToolExecutionFailure {\n        readonly isError: true;\n        readonly error: ToolFailure;\n        readonly value?: never;\n        readonly content: ContentBlock[];\n        readonly meta?: JsonValue;\n        readonly additionalContexts?: UserMessage[];\n        readonly concludesTurn?: never;\n    }\n    export interface ToolExecutionInput {\n        readonly callId: CallId;\n        readonly name: string;\n        readonly arguments: unknown;\n        readonly agent?: Agent;\n        readonly parent?: ToolExecutionToken;\n        readonly signal: AbortSignal;\n    }\n    export type ToolExecutionMode = {\n        kind: 'parallel';\n    } | {\n        kind: 'exclusive';\n    };\n    export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n    export interface ToolExecutionSuccess {\n        readonly isError: false;\n        readonly value: JsonValue;\n        readonly content: ContentBlock[];\n        readonly error?: never;\n        readonly meta?: JsonValue;\n        readonly additionalContexts?: UserMessage[];\n        readonly concludesTurn?: true;\n    }\n    export type ToolExecutionToken = symbol & {\n        readonly [toolExecutionTokenBrand]: true;\n    };\n    export interface ToolFailure {\n        message: string;\n        info?: ToolErrorInfo;\n    }\n    export type ToolGuard = (execution: Readonly) => string | undefined;\n    export interface ToolMessageSource {\n        kind: 'tool';\n        callId: CallId;\n    }\n    export interface ToolOutputDefinition {\n        readonly schema: JsonSchemaNode;\n        render(args: unknown, value: JsonValue): ContentBlock[];\n        presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n    }\n    export interface ToolRestriction {\n        readonly allow?: readonly string[];\n        readonly deny?: readonly string[];\n    }\n    export interface ToolResult {\n        content: ContentBlock[];\n        isError: boolean;\n        meta?: JsonValue;\n    }\n    export interface ToolResultBlock {\n        type: 'tool-result';\n        toolCallId: CallId;\n        content: ContentBlock[];\n        isError?: boolean;\n    }\n    export interface ToolResultMessage extends Message {\n        readonly role: 'user';\n        readonly content: [\n            ToolResultBlock\n        ];\n        readonly source: ToolMessageSource;\n    }\n    export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n    export interface ToolRunContext extends ToolExecution {\n        deferContext(context: UserMessage): void;\n        concludeTurn(): void;\n    }\n    export interface ToolSchema {\n        name: string;\n        description: string;\n        parameters: Record;\n    }\n    export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n    export interface TurnEndReasonMap {\n        completed: {\n            kind: 'completed';\n        };\n        aborted: {\n            kind: 'aborted';\n        };\n        error: {\n            kind: 'error';\n            step: number;\n        } & ({\n            failure: LlmFailure;\n            message?: never;\n            code?: never;\n        } | {\n            message: string;\n            code?: string;\n            failure?: never;\n        });\n        disposed: {\n            kind: 'disposed';\n        };\n        'max-tokens': {\n            kind: 'max-tokens';\n        };\n        interrupted: {\n            kind: 'interrupted';\n        };\n    }\n    export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n    export interface TurnTriggerMap {\n        message: {\n            kind: 'message';\n            source: MessageSource;\n        };\n        retry: {\n            kind: 'retry';\n        };\n        injection: {\n            kind: 'injection';\n            source: MessageSource;\n        };\n    }\n    export interface UserMessage extends Message {\n        readonly role: 'user';\n    }\n    export interface WebFetchResultView {\n        card: 'web';\n        kind: 'fetch';\n        title?: string;\n        url: string;\n        statusCode: number;\n        truncated: boolean;\n    }\n    export type WebResultView = WebSearchResultView | WebFetchResultView;\n    export interface WebSearchResultView {\n        card: 'web';\n        kind: 'search';\n        title?: string;\n        sources: WebSource[];\n        answer?: string;\n        truncated: boolean;\n    }\n    export interface WebSource {\n        url: string;\n        title?: string;\n        snippet?: string;\n        publishedAt?: string;\n    }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
 {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
index e4b84dee8c..1420353ae4 100644
--- a/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
@@ -14,7 +14,7 @@
 {"type":"assistant/chunk","seq":68,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":69,"time":1783352086059,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."},{"type":"tool-call","id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"36ebf262-429c-4398-abbc-a197e2522f1d"},"usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"surfaceOp":"append"}
 {"type":"tool/call","seq":70,"time":1783352086059,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}}
-{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"}},"sourceEventSeqs":[70],"surfaceOp":"append"}
+{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}],"isError":false}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"},"meta":{"path":"{{cwd}}/config.txt","offset":1,"lines":[{"number":1,"text":"mode=DEBUG"},{"number":2,"text":"level=info"}],"totalLines":2}},"sourceEventSeqs":[70],"surfaceOp":"append"}
 {"type":"step/end","seq":72,"time":1783352086065,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":73,"time":1783352086066,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":74,"time":1783352086901,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
index 7ff933ab4b..f58a07edfe 100644
--- a/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
@@ -27,7 +27,7 @@
 {"type":"assistant/chunk","seq":143,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":144,"time":1783611705573,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."},{"type":"tool-call","id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"de588e3c-b10c-4eee-93a5-26e9a665dcbc"},"usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}},"sourceEventSeqs":[82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143],"surfaceOp":"append"}
 {"type":"tool/call","seq":145,"time":1783611705573,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}}
-{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"{{cwd}}/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"}},"sourceEventSeqs":[145],"surfaceOp":"append"}
+{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"{{cwd}}/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"},"meta":{"path":"{{cwd}}/settings.txt","offset":1,"lines":[{"number":1,"text":"color: blue"}],"totalLines":1}},"sourceEventSeqs":[145],"surfaceOp":"append"}
 {"type":"step/end","seq":147,"time":1783611705579,"data":{"turn":1,"step":2}}
 {"type":"step/start","seq":148,"time":1783611705579,"data":{"turn":1,"step":3}}
 {"type":"assistant/chunk","seq":149,"time":1783611706200,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
index 58cb8c5f34..33b0a2754f 100644
--- a/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
@@ -14,7 +14,7 @@
 {"type":"assistant/chunk","seq":90,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":91,"time":1783352101348,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the read tool with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE.\n\nLet me first check the current directory, then read the file."},{"type":"tool-call","id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5620412c-8fae-4d17-aac4-0801f3b02461"},"usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
 {"type":"tool/call","seq":92,"time":1783352101348,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}}
-{"type":"tool/result","seq":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"{{cwd}}/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}],"isError":false}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"}},"sourceEventSeqs":[92],"surfaceOp":"append"}
+{"type":"tool/result","seq":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"{{cwd}}/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}],"isError":false}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"},"meta":{"path":"{{cwd}}/big.txt","offset":5,"lines":[{"number":5,"text":"line five"},{"number":6,"text":"line six"},{"number":7,"text":"line seven"},{"number":8,"text":"line eight"}],"totalLines":10}},"sourceEventSeqs":[92],"surfaceOp":"append"}
 {"type":"step/end","seq":94,"time":1783352101353,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":95,"time":1783352101354,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":96,"time":1783352102021,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl
index 70c0f679bd..9d16f66531 100644
--- a/examples/acp-agent/tests/snapshots/fs-read/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-read/session.jsonl
@@ -14,7 +14,7 @@
 {"type":"assistant/chunk","seq":52,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":53,"time":1783352073708,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."},{"type":"tool-call","id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5452254c-4843-458c-9732-12fe8b7c1468"},"usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"surfaceOp":"append"}
 {"type":"tool/call","seq":54,"time":1783352073709,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}
-{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"}},"sourceEventSeqs":[54],"surfaceOp":"append"}
+{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"},"meta":{"path":"{{cwd}}/greeting.txt","offset":1,"lines":[{"number":1,"text":"hello"}],"totalLines":1}},"sourceEventSeqs":[54],"surfaceOp":"append"}
 {"type":"step/end","seq":56,"time":1783352073718,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":57,"time":1783352073719,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":58,"time":1783352074666,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
index e836616e19..4fdc8bae84 100644
--- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
@@ -14,7 +14,7 @@
 {"type":"assistant/chunk","seq":64,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":65,"time":1783352093617,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""},{"type":"tool-call","id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"00272d0c-8ed0-436a-8d10-4a7091447dfe"},"usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64],"surfaceOp":"append"}
 {"type":"tool/call","seq":66,"time":1783352093617,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}
-{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"}},"sourceEventSeqs":[66],"surfaceOp":"append"}
+{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"},"meta":{"path":"{{cwd}}/data.txt","offset":1,"lines":[{"number":1,"text":"original contents"}],"totalLines":1}},"sourceEventSeqs":[66],"surfaceOp":"append"}
 {"type":"step/end","seq":68,"time":1783352093624,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":69,"time":1783352093625,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":70,"time":1783352094455,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
index d4cca7a82c..7c22270112 100644
--- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
@@ -15,8 +15,8 @@
 {"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"380ff5b4-d7f1-4c36-b87d-9a42ce1b264c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"}
 {"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}
 {"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}
-{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ccdf47d2-0e79-4ca6-a70f-2c8c42e2341e"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
-{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"49a6bffd-3a0e-490e-bf49-e9d3e6370f83"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
+{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"ccdf47d2-0e79-4ca6-a70f-2c8c42e2341e"},"meta":{"path":"{{cwd}}/a.txt","offset":1,"lines":[{"number":1,"text":"alpha"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"}
+{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"49a6bffd-3a0e-490e-bf49-e9d3e6370f83"},"meta":{"path":"{{cwd}}/b.txt","offset":1,"lines":[{"number":1,"text":"beta"}],"totalLines":1}},"sourceEventSeqs":[15],"surfaceOp":"append"}
 {"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
diff --git a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
index 832916d334..f2157a27fe 100644
--- a/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
@@ -12,7 +12,7 @@
 {"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":11,"time":1784903339801,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2093472f-8f2c-4cfd-8d71-515e3242dad2"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"}
 {"type":"tool/call","seq":12,"time":1784903339802,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}
-{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"d9a6b9c3-715b-42b0-9d20-04319a83eea8"}},"sourceEventSeqs":[12],"surfaceOp":"append"}
+{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"d9a6b9c3-715b-42b0-9d20-04319a83eea8"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[12],"surfaceOp":"append"}
 {"type":"user/message","seq":14,"time":1784903339813,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"68629935-05e9-4af7-bddb-aabfbbd70208"},"surfaceOp":"append"}
 {"type":"step/end","seq":15,"time":1784903339813,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":16,"time":1784903339820,"data":{"turn":1,"step":2}}
@@ -23,7 +23,7 @@
 {"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":22,"time":1784903339821,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"04824453-a12a-43d7-8580-4b75d0e4a694"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"}
 {"type":"tool/call","seq":23,"time":1785394278014,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope/task.txt\"}"}}
-{"type":"tool/result","seq":24,"time":1785394278026,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"06c93dad-5ee2-4c56-ba7b-c1228bee7090"}},"sourceEventSeqs":[23],"surfaceOp":"append"}
+{"type":"tool/result","seq":24,"time":1785394278026,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"06c93dad-5ee2-4c56-ba7b-c1228bee7090"},"meta":{"path":"{{cwd}}/scope/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[23],"surfaceOp":"append"}
 {"type":"user/message","seq":25,"time":1785394278026,"data":{"content":[{"type":"text","text":"\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope\u0000AGENTS.md","path":"scope/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"7bcf58d7-7f2f-4242-8bd6-00577c9c3153"},"surfaceOp":"append"}
 {"type":"step/end","seq":26,"time":1785394278026,"data":{"turn":1,"step":2}}
 {"type":"step/start","seq":27,"time":1785394278034,"data":{"turn":1,"step":3}}
diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
index ed61019baa..0b32d5ceac 100644
--- a/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
@@ -14,7 +14,7 @@
 {"type":"assistant/chunk","seq":78,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":79,"time":1783352265491,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."},{"type":"tool-call","id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3f154ea9-6cf0-4d0a-a478-503962bfe8e1"},"usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"}
 {"type":"tool/call","seq":80,"time":1783352265491,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}
-{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"}},"sourceEventSeqs":[80],"surfaceOp":"append"}
+{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"},"meta":{"path":"{{cwd}}/greeting.txt","offset":1,"lines":[{"number":1,"text":"hello"}],"totalLines":1}},"sourceEventSeqs":[80],"surfaceOp":"append"}
 {"type":"step/end","seq":82,"time":1783352265504,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":83,"time":1783352265505,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":84,"time":1783352266385,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
diff --git a/package.json b/package.json
index fc315cb146..96f72d1c1c 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
     "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts",
     "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts",
     "test:web": "npm run build && npm run test:web:built",
+    "test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts",
     "test:web:built": "vitest run --config vitest.web.config.ts",
     "test:gui": "vitest run packages/client packages/host",
     "check:all": "tsx scripts/run-gates.ts check-all",
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 4b22213c15..49f0aaf9dc 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -300,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
@@ -413,9 +420,26 @@ 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.
@@ -945,6 +969,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
     updatedAt: fixtureEpoch,
   }]
   let nextWorkspace = 1
+  // Registry-global archive set mirroring the host: archived sessions keep
+  // their workspace accounting slot and only grouping surfaces hide them.
+  const archivedSessionIds: SessionId[] = []
 
   // In-memory browse tree behind the fixture's `browse` picker capability —
   // deterministic content mirroring the design mock so assembled Web tests
@@ -1599,7 +1626,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
       openPath: request => ok(request, { opened: true as const }),
     },
     workspace: {
-      list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
+      list: request => ok(request, {
+        items: workspaces.map(w => ({ ...w })),
+        archivedSessionIds: [...archivedSessionIds],
+      }),
       create: (request) => {
         const { path, name } = request.payload
         const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}`
@@ -1685,6 +1715,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
         }
         return ok(request, { workspace: { ...workspace } })
       },
+      archiveSession: (request) => {
+        const missing = requireSession(request)
+        if (missing !== undefined) return missing
+        const { sessionId } = request.payload
+        if (!archivedSessionIds.includes(sessionId)) {
+          archivedSessionIds.push(sessionId)
+          emitHost({ type: 'host/archived-sessions-changed', archivedSessionIds: [...archivedSessionIds] })
+        }
+        return ok(request, { archivedSessionIds: [...archivedSessionIds] })
+      },
     },
     commands: {
       // The catalog mirrors one session's effective view (every fixture
@@ -2065,6 +2105,7 @@ export class FixtureApiClient extends AbstractApiClient {
       case 'workspace.rename': return this.api.workspace.rename(request)
       case 'workspace.delete': return this.api.workspace.delete(request)
       case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
+      case 'workspace.archiveSession': return this.api.workspace.archiveSession(request)
       case 'command.list': return this.api.commands.list(request)
       case 'command.execute': return this.api.commands.execute(request, signal)
       case 'skill.list': return this.api.skills.list(request)
diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts
index 0d58800279..d599de1be7 100644
--- a/packages/client/connection/tests/fake-api.ts
+++ b/packages/client/connection/tests/fake-api.ts
@@ -122,7 +122,7 @@ export class FakeApiClient implements IApiClient {
   }
 
   readonly workspace: IApiClient['workspace'] = {
-    list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))),
+    list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [], archivedSessionIds: [] }))),
     create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({
       workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
       created: true,
@@ -134,6 +134,9 @@ export class FakeApiClient implements IApiClient {
     insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
       workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
     }))),
+    archiveSession: (payload: unknown) => this.record('workspace.archiveSession', payload, Promise.resolve(ok({
+      archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId],
+    }))),
   }
 
   // Payloads stay `unknown` (lint-lane note above); response rows are the real
diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx
index 2fdc5d5f45..2908583191 100644
--- a/packages/client/locale/tests/language-row.spec.tsx
+++ b/packages/client/locale/tests/language-row.spec.tsx
@@ -21,7 +21,7 @@ function emptySessions() {
 }
 function emptyWorkspaces() {
   const store = createSnapshotStore({
-    items: [], state: 'idle', phase: 'ready', error: null,
+    items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
     baselinesReady: true, recentWorkspaceId: undefined,
   })
   return bindSnapshotSelector(store)
diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml
index f42e4f09d0..60d011aeb1 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: 9f2b165f1a98dcecfa3ab82386da9b094cfd2f54
-README.zh.md: 3ed047e65d3bddc14c3b6b84f327bbeebf805d4b
+README.md: 022dc6f82ea7aa1490144449ea61a84a512906a2
+README.zh.md: 4d0f74f573a5e03b05755cfcfea930e69ef386e2
diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md
index 9f2b165f1a..022dc6f82e 100644
--- a/packages/client/runtime/README.md
+++ b/packages/client/runtime/README.md
@@ -10,6 +10,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
 
 `WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.
 
+`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store.
+
 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.
diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md
index 3ed047e65d..4d0f74f573 100644
--- a/packages/client/runtime/README.zh.md
+++ b/packages/client/runtime/README.zh.md
@@ -10,6 +10,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
 
 `WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
 
+`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。
+
 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 不携带它。
diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts
index 9238ea5fd0..3e64ef3717 100644
--- a/packages/client/runtime/src/client/contract/workspaces.ts
+++ b/packages/client/runtime/src/client/contract/workspaces.ts
@@ -76,4 +76,11 @@ export interface IWorkspaces {
    * @returns the updated Workspace view.
    */
   insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise
+  /**
+   * Archive a session into the registry-global set (hidden from grouping
+   * surfaces; session log and accounting slot remain). Archiving the current
+   * session clears the selection into the New Session view state.
+   * @param sessionId - session to archive.
+   */
+  archiveSession(sessionId: SessionId): Promise
 }
diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts
index ce4198cd01..ccf0c46fe1 100644
--- a/packages/client/runtime/src/client/workspaces/manager.ts
+++ b/packages/client/runtime/src/client/workspaces/manager.ts
@@ -14,6 +14,14 @@ export type WorkspaceListPhase = 'pending' | 'ready'
 /** Immutable workspace-list snapshot. */
 export interface WorkspaceListSnapshot {
   items: readonly WorkspaceView[]
+  /**
+   * Registry-global archive set in Host order (hidden from grouping
+   * surfaces; accounting slots retained). A plain array, not a Set: public
+   * snapshot state stays in the store engine's plain-data vocabulary
+   * (immer drafts reject Sets without the MapSet plugin); membership
+   * lookups build their own transient Set where they need one.
+   */
+  archivedSessionIds: readonly SessionId[]
   state: 'idle' | 'loading' | 'error'
   phase: WorkspaceListPhase
   error: RpcError | null
@@ -28,11 +36,21 @@ export class WorkspaceManager {
   private items: Workspace[] = []
   private itemViewsSource: readonly Workspace[] | null = null
   private itemViewsCache: readonly WorkspaceView[] = []
+  // Full-snapshot state (list response / unary response / changed frame all
+  // carry the complete set), so deltas never merge — installs replace.
+  private archivedSessionIds: readonly SessionId[] = []
   private state: WorkspaceListSnapshot['state'] = 'idle'
   private phase: WorkspaceListPhase = 'pending'
   private error: RpcError | null = null
   private inflight: Promise | null = null
   private refreshFrames: WorkspaceDelta[] | null = null
+  /**
+   * True once a frame or unary echo installed the archive set while a list
+   * request was in flight: that install is newer than the pending baseline,
+   * so the baseline's (older) set must not roll it back — the archive
+   * mirror of replaying refreshFrames over the item baseline.
+   */
+  private archivedSupersedesRefresh = false
   /**
    * Ids this process has seen removed, kept for the connection's lifetime so
    * a late changed frame or a stale baseline row cannot resurrect a deleted
@@ -77,6 +95,7 @@ export class WorkspaceManager {
           items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
           for (const delta of frames) items = applyWorkspaceDelta(items, delta)
           this.installViews(items)
+          if (!this.archivedSupersedesRefresh) this.installArchived(result.value.archivedSessionIds)
           this.state = 'idle'
           this.phase = 'ready'
         } else {
@@ -90,6 +109,7 @@ export class WorkspaceManager {
         this.error = folded.ok ? null : folded.error
       } finally {
         this.refreshFrames = null
+        this.archivedSupersedesRefresh = false
         this.inflight = null
         this.notifier.markDirty()
       }
@@ -158,6 +178,18 @@ export class WorkspaceManager {
     return result
   }
 
+  /**
+   * Archive one session in the registry-global set, then install the
+   * returned full set without waiting for the changed frame.
+   * @param sessionId - session to archive.
+   * @returns the wire result.
+   */
+  async archiveSession(sessionId: SessionId): Promise> {
+    const { result } = await this.api.workspace.archiveSession({ sessionId })
+    if (result.ok) this.installArchived(result.value.archivedSessionIds)
+    return result
+  }
+
   /**
    * Host-frame entry. Non-workspace frames are ignored so the runtime can
    * fan one host stream out to both object managers.
@@ -166,6 +198,9 @@ export class WorkspaceManager {
   handleHostEnvelope(envelope: RpcRequest): void {
     if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
     else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
+    else if (envelope.payload.type === 'host/archived-sessions-changed') {
+      this.installArchived(envelope.payload.archivedSessionIds)
+    }
   }
 
   /** Re-pull the baseline after each connection generation. */
@@ -194,12 +229,26 @@ export class WorkspaceManager {
   private buildSnapshot(): WorkspaceListSnapshot {
     return {
       items: this.itemViews(),
+      archivedSessionIds: this.archivedSessionIds,
       state: this.state,
       phase: this.phase,
       error: this.error,
     }
   }
 
+  /**
+   * Replace the archive set when membership actually changed (array identity
+   * backs Object.is short-circuits). Host snapshots are append-ordered, so
+   * positional comparison is exact, not merely heuristic.
+   */
+  private installArchived(archivedSessionIds: readonly SessionId[]): void {
+    if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true
+    if (archivedSessionIds.length === this.archivedSessionIds.length
+      && archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return
+    this.archivedSessionIds = [...archivedSessionIds]
+    this.notifier.markDirty()
+  }
+
   /** Upsert one Host view, optionally retaining the local object that materialized it. */
   private upsert(view: WorkspaceView, identity?: Workspace): void {
     if (this.removedIds.has(view.workspaceId)) return
diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts
index 1dd3319e79..a0a76670f2 100644
--- a/packages/client/runtime/src/client/workspaces/service.ts
+++ b/packages/client/runtime/src/client/workspaces/service.ts
@@ -14,6 +14,14 @@ import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
 /** Workspace list plus the two-baseline readiness and default-target projection. */
 export interface WorkspaceListState {
   items: readonly WorkspaceView[]
+  /**
+   * Registry-global archive set in Host order: grouping surfaces hide these
+   * sessions everywhere (workspace groups and the ungrouped bucket) while
+   * their session logs and workspace accounting slots remain. A plain array
+   * (store-engine vocabulary; immer drafts reject Sets) — membership lookups
+   * build their own transient Set.
+   */
+  archivedSessionIds: readonly SessionId[]
   state: 'idle' | 'loading' | 'error'
   phase: WorkspaceListPhase
   error: RpcError | null
@@ -58,7 +66,7 @@ export class WorkspacesService implements IWorkspaces {
   constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
     this.manager = new WorkspaceManager(api)
     this.list = createSnapshotStore({
-      items: [], state: 'idle', phase: 'pending', error: null,
+      items: [], archivedSessionIds: [], state: 'idle', phase: 'pending', error: null,
       baselinesReady: false, recentWorkspaceId: undefined,
     })
     this.manager.subscribe(() => { this.project() })
@@ -88,10 +96,14 @@ export class WorkspacesService implements IWorkspaces {
     if (inflight !== undefined) return inflight
     // Reuse: blank && same canonical cwd (workspace.path is the host realpath
     // canon; summary cwd is the session header passthrough of the same canon).
+    // An archived blank is never reused: reuse would open a session no
+    // grouping surface can show, so New Session mints a fresh one instead.
+    const archived = this.list.getSnapshot().archivedSessionIds
     const sessions = this.sessions.list.getSnapshot()
     for (const id of sessions.ids) {
       const summary = sessions.byId[id]
-      if (summary !== undefined && summary.blank && summary.cwd === workspace.path) return summary.id
+      if (summary !== undefined && summary.blank && summary.cwd === workspace.path
+        && !archived.includes(summary.id)) return summary.id
     }
     const attempt = this.sessions.create({ workspaceId })
       .finally(() => { this.connecting.delete(workspaceId) })
@@ -249,6 +261,17 @@ export class WorkspacesService implements IWorkspaces {
     if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
   }
 
+  /**
+   * Archive a session into the registry-global set. Clearing an archived
+   * current selection is the projection sweep's job (one rule for the local
+   * echo and a remote tab's frame alike).
+   * @param sessionId - session to archive.
+   */
+  async archiveSession(sessionId: SessionId): Promise {
+    const result = await this.manager.archiveSession(sessionId)
+    if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`)
+  }
+
   /**
    * Move a session within its Workspace's manual order (DOM-insertBefore-like).
    * @param workspaceId - owning workspace.
@@ -291,8 +314,17 @@ export class WorkspacesService implements IWorkspaces {
     const workspace = this.manager.getSnapshot()
     const sessions = this.sessions.list.getSnapshot()
     const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
+    // An archived current selection clears into the New Session view state —
+    // a hidden row must not stay open behind the list. Sweeping here covers
+    // every install path with one rule: the local unary echo, another tab's
+    // changed frame, and a reconnect baseline restoring a persisted
+    // selection that was archived while this client was away.
+    if (sessions.current !== undefined && workspace.archivedSessionIds.includes(sessions.current)) {
+      this.sessions.clear()
+    }
     this.list.set({
       items: workspace.items,
+      archivedSessionIds: workspace.archivedSessionIds,
       state: workspace.state,
       phase: workspace.phase,
       error: workspace.error,
diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts
index 06d948ae83..9cf7971049 100644
--- a/packages/client/runtime/tests/fake-api.ts
+++ b/packages/client/runtime/tests/fake-api.ts
@@ -140,7 +140,10 @@ export class FakeApiClient implements IApiClient {
     openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
   }
 
-  onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] }))
+  // The archive-set field defaults at the binding below so list stubs keep
+  // the pre-archive `{ items }` shape; a stub carrying the field wins.
+  onWorkspaceList: (payload: unknown) => Promise> =
+    () => Promise.resolve(ok({ items: [] }))
   onWorkspaceCreate: (payload: unknown) => Promise> =
     () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
 
@@ -153,13 +156,22 @@ export class FakeApiClient implements IApiClient {
   onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> =
     () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
 
+  onWorkspaceArchiveSession: (payload: unknown) => Promise> =
+    payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] }))
+
   readonly workspace: IApiClient['workspace'] = {
-    list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
+    list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload).then(response => (
+      response.result.ok
+        ? { ...response, result: { ok: true as const, value: { archivedSessionIds: [] as never[], ...response.result.value } } }
+        : response
+    )) as ReturnType),
     create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
     rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
     delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)),
     insertSessionBefore: (payload: unknown) =>
       this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
+    archiveSession: (payload: unknown) =>
+      this.record('workspace.archiveSession', payload, this.onWorkspaceArchiveSession(payload)),
   }
 
   // Payloads stay `unknown` (lint-lane note above); response rows are the real
diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts
index 7fd6934827..4323d7ffce 100644
--- a/packages/client/runtime/tests/workspaces-service.spec.ts
+++ b/packages/client/runtime/tests/workspaces-service.spec.ts
@@ -183,6 +183,12 @@ describe('WorkspacesService', () => {
 
     // Unknown workspace fails loud instead of silently creating in nowhere.
     await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
+
+    // An archived blank is never reused: no surface can show it, so New
+    // Session mints a fresh one for alpha instead.
+    await workspaces.archiveSession(sid('s-blank'))
+    api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-2') }))
+    await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-fresh-2')
   })
 
   it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
@@ -285,6 +291,84 @@ describe('WorkspacesService', () => {
     }))
     await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
   })
+
+  it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => {
+    const ctx = new Context()
+    const api = new FakeApiClient()
+    const sessions = new SessionsService(ctx, api)
+    const workspaces = new WorkspacesService(ctx, api, sessions)
+    api.onList = () => Promise.resolve(ok({
+      items: [
+        { sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false },
+        { sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false },
+      ],
+    }) as never)
+    await sessions.refresh()
+    sessions.open(sid('s-open'))
+
+    // Archiving a non-current session installs the unary echo and keeps the selection.
+    await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined()
+    expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }])
+    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
+    expect(sessions.list.getSnapshot().current).toBe('s-open')
+
+    // Archiving the current session clears it into the New Session view state.
+    api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] }))
+    await workspaces.archiveSession(sid('s-open'))
+    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
+    expect(sessions.list.getSnapshot().current).toBeUndefined()
+
+    // A Host failure leaves the set and the selection untouched.
+    api.onWorkspaceArchiveSession = () => Promise.resolve(err({
+      code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') },
+    }))
+    await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/)
+    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
+
+    // The changed frame and the list baseline both re-install the full set.
+    workspaces.handleHostEnvelope({
+      rpcId: 'frame' as never,
+      payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-idle')] },
+    } as never)
+    // Frame installs ride the notifier's microtask batch before projecting.
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
+    api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never)
+    await workspaces.refresh()
+    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
+  })
+
+  it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => {
+    const ctx = new Context()
+    const api = new FakeApiClient()
+    const sessions = new SessionsService(ctx, api)
+    const workspaces = new WorkspacesService(ctx, api, sessions)
+    api.onList = () => Promise.resolve(ok({
+      items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
+    }) as never)
+    await sessions.refresh()
+    sessions.open(sid('s-open'))
+
+    // A stale baseline is in flight (older, empty set) when another tab's
+    // archive frame lands: the frame clears the current selection and its
+    // set survives the baseline's later resolution.
+    const gate = deferred>>()
+    api.onWorkspaceList = () => gate.promise
+    const hydration = workspaces.refresh()
+    workspaces.handleHostEnvelope({
+      rpcId: 'frame' as never,
+      payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-open')] },
+    } as never)
+    await new Promise(resolve => setTimeout(resolve, 0))
+    expect(sessions.list.getSnapshot().current).toBeUndefined()
+    gate.resolve(ok({ items: [], archivedSessionIds: [] }))
+    await hydration
+    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
+    // The next (fresh) baseline is authoritative again.
+    api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never)
+    await workspaces.refresh()
+    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([])
+  })
 })
 
 describe('startInitialSelection', () => {
diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts
index 4219d233e2..e4c9ef946d 100644
--- a/packages/client/test-runtime/src/fixtures.ts
+++ b/packages/client/test-runtime/src/fixtures.ts
@@ -73,6 +73,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
 export function workspaceListState(): WorkspaceListState {
   return {
     items: [],
+    archivedSessionIds: [],
     state: 'idle',
     phase: 'ready',
     error: null,
diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts
index 6c1a9d0aad..7e626a3660 100644
--- a/packages/client/test-runtime/src/workspaces.ts
+++ b/packages/client/test-runtime/src/workspaces.ts
@@ -186,4 +186,21 @@ export class TestWorkspaces implements IWorkspaces {
     if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise)
     return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView
   }
+
+  /**
+   * Archive a session (recorded). The default mirrors the production face's
+   * observable effect: the id joins the list state's archive set.
+   * @param sessionId - session to archive.
+   */
+  async archiveSession(sessionId: SessionId): Promise {
+    this.calls.push({ method: 'archiveSession', args: [sessionId] })
+    const stub = this.stubs.get('archiveSession')
+    if (stub !== undefined) {
+      await (stub(sessionId) as Promise)
+      return
+    }
+    await this.update((draft) => {
+      draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId]
+    })
+  }
 }
diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx
index 8909f88162..3675671f26 100644
--- a/packages/client/test-runtime/tests/runtime.spec.tsx
+++ b/packages/client/test-runtime/tests/runtime.spec.tsx
@@ -551,8 +551,12 @@ describe('workspaces action face', () => {
     await ws.openPath('/proj/file.ts')
     const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
     expect(moved.sessionIds).toEqual(['s1'])
+    // Default archive mirrors the production effect: the id joins the list
+    // state's archive set (features render against the same snapshot).
+    await ws.archiveSession('s1' as SessionId)
+    expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
     expect(ws.calls.map(c => c.method)).toEqual(
-      ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore'])
+      ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession'])
 
     ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
     ws.stub('pickDirectory', () => Promise.resolve('/picked'))
@@ -560,12 +564,16 @@ describe('workspaces action face', () => {
     ws.stub('delete', () => Promise.resolve())
     ws.stub('openPath', () => Promise.resolve())
     ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
+    ws.stub('archiveSession', () => Promise.resolve())
     expect((await ws.create({ name: 'y' })).title).toBe('X')
     await expect(ws.pickDirectory()).resolves.toBe('/picked')
     expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
     await ws.delete('w1' as WorkspaceId)
     await ws.openPath('/other')
     expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
+    // The stub replaces the default set mutation: the set stays as-is.
+    await ws.archiveSession('s2' as SessionId)
+    expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
     await runtime.dispose()
   })
 })
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 90b602ded3..28068bdc5f 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: 3e0bca6610e5503e4c2c1f9fe5ad7a07bbcbdd2a
-README.zh.md: 1606461fc1060825133bb0c3f3f6381bda3926ad
+README.md: 7c6e36409efd5f2f9224e85a9cbd3a5e515833c1
+README.zh.md: 7661826153bc44ff47a660fa49a4ffd902d93bdd
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 3e0bca6610..7c6e36409e 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -18,6 +18,8 @@ A tool call declaring the `terminal` render intent renders its command output in
 
 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).
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 1606461fc1..7661826153 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -16,6 +16,8 @@
 
 声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
 
+声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result 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 会拒绝没有任何渲染方的声明)。
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 24c26ab432..0be0f4f534 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -20,6 +20,7 @@ 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'
@@ -319,6 +320,10 @@ 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).
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
index d35e7c0721..faadcf090b 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
@@ -10,6 +10,7 @@ import {
   IconThinkOutline14, WebBlock,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
+import { diffCardModel } from '../contract/diff-card-model.ts'
 import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
 import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
 import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -36,6 +37,7 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
 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.
@@ -53,10 +55,14 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
       // A terminal presenter's description is the contract's above-card text, so
       // it outranks the args-derived summary here exactly as it does in BashRow.
       summary={terminal?.description ?? model.summary}
-      body={model.body}
+      // Single-file tools never expose an args body — the path link is the only
+      // args interaction. A diff card is not an args body: a write/edit row is
+      // single-file AND carries a diff, so the card expands under the path link.
+      body={singleFile ? null : model.body}
       output={model.output}
       errorSummary={model.errorSummary}
       terminal={terminal}
+      diff={diff}
       state={state}
       filePath={model.filePath}
       onOpenFile={singleFile ? openFile : undefined}
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 43cb37462a..030a6f0d80 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -257,6 +257,12 @@
   margin: 4px 0 4px 4px;
 }
 
+/* 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 {
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index 9413e1809c..40a5824ce3 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -18,8 +18,9 @@
 
 import { useState, type MouseEvent, type ReactNode } from 'react'
 import clsx from 'clsx'
-import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import { CodeBlock, DiffBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
+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'
@@ -48,6 +49,13 @@ export interface ToolRowProps {
    * 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
   /**
    * Filesystem path from tool args; when set with onOpenFile, the summary
@@ -95,6 +103,7 @@ export function ToolRow({
   output,
   errorSummary,
   terminal,
+  diff,
   state,
   filePath,
   onOpenFile,
@@ -102,8 +111,9 @@ export function ToolRow({
 }: ToolRowProps) {
   const [expanded, setExpanded] = useState(false)
   const terminalBody = terminal ?? null
+  const diffBody = diff ?? null
   const outputText = output ?? null
-  const expandable = body !== null || outputText !== null || terminalBody !== 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.
@@ -175,38 +185,40 @@ export function ToolRow({
                 className={css.terminalBody}
               />
             )
-            : isThink
-              ? 
{body}
- : ( - <> - {variant === 'code' && body !== null && ( -
- -
- )} - {(cardBody !== null || outputText !== null) && ( -
- {cardBody !== null && ( -
- IN - {cardBody} -
- )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
- OUT - - {outputText} - -
- )} -
- )} - - )} + : 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 && ( + ) : ( + {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/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index a8e0e06aec..239f690998 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -84,13 +84,14 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample, the web rows, and the product rows as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => { const b = await bench() // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the // service being present implies the chat entry declared the hole first. The + // file-mutation registrant claims both write and edit for the diff card; 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', 'web_search', 'web_fetch', '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-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 49eeec29fd..1f9b8b7356 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -128,7 +128,7 @@ async function bench(snapshot: ConversationSnapshot) { ctx.provide('sessions', sessionsFake) const workspaces = { list: createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }), startSession: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index bfaab93375..ca81d225a9 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -94,7 +94,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) 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..e25045c9f0 --- /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: [], archivedSessionIds: [], 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: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + 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/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 354b94ecdd..4de44d367c 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -74,7 +74,7 @@ describe('render branch tails', () => { const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }) const emptyWorkspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) const view = render( @@ -111,7 +111,7 @@ describe('render branch tails', () => { const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }) const emptyWorkspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) const view = render( diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 6831b1a5a0..e8154ca957 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -96,7 +96,7 @@ function bench(over?: BenchOptions) { ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: ((key: string, selector?: (v: unknown) => unknown) => diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index f619d380a0..04f20d56c6 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -39,7 +39,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 60b02795fe..11ee8fa4e4 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -125,7 +125,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a37074bd07..3841930d2c 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -62,7 +62,7 @@ function workspace(id = 'w1'): WorkspaceView { } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, state: 'idle', phase: 'ready', error: null, + items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 213897e0f5..71de19b152 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -429,7 +429,7 @@ describe('DetailsPanel Output section', () => { phase: 'ready', }) const workspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return render( @@ -607,7 +607,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }))} useWorkspaces={bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }))} useInput={(() => { throw new Error('unused') })} diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index cea95e61f6..fc4a27c78b 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -190,7 +190,7 @@ describe('DetailsPanel web Output section', () => { 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, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return render( diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index ab8a11706d..11b5e48e0a 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -77,7 +77,7 @@ function mountFrame() { return sel(sessionState) }) as never const workspaceState: WorkspaceListState = { - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: baselinesReady.current, recentWorkspaceId: undefined, } const element = () => ( diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 951b1d04fe..0cd3fa6269 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: adfbc084e1b0e227d50032cb6c924401b81c6a79 -README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef +README.md: 4edb34ccbe8f628c04e410a6dd2f002e247623f3 +README.zh.md: 68a1e0ee205d3ba764620bcfeba7c11a88ee8736 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index adfbc084e1..4edb34ccbe 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time. +Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base). +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 4ee7d4efa7..68a1e0ee20 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。 +模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。 diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index a2be484a63..a8b28db46c 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -3,6 +3,7 @@ flex-direction: column; gap: 12px; max-width: 720px; + color: var(--dsw-alias-label-primary); } .title { @@ -14,13 +15,13 @@ .intro { margin: 0; font-size: 13px; - color: var(--text-tertiary, #888); + color: var(--dsw-alias-label-tertiary); } .notice { margin: 0; font-size: 12px; - color: var(--text-warning, #a15c00); + color: var(--dsw-alias-state-warn-label); } .rows { @@ -33,13 +34,13 @@ } .rowCard { - border: 1px solid var(--border, #e2e2e2); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; - background: var(--surface, #fff); + background: var(--dsw-alias-bg-layer-3); } .rowHead { @@ -53,58 +54,27 @@ font-weight: 600; } -.badges { - display: inline-flex; - gap: 6px; - flex: 1; -} - -.badgeOk { - display: inline-flex; - align-items: center; - gap: 5px; - color: var(--text-success, #0a7d33); - font-size: 12px; -} - -.badgeOk::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 999px; - background: currentcolor; -} - -.badgeMuted { - color: var(--text-tertiary, #999); - font-size: 12px; -} - -.badgeWarn { - color: var(--text-warning, #a15c00); - font-size: 12px; -} - .rowActions { display: inline-flex; gap: 8px; + margin-left: auto; } .primaryButton { border: none; border-radius: 999px; padding: 8px 18px; - background: var(--accent-strong, #111); - color: var(--text-inverse, #fff); + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-label-primary-foreground); font: inherit; cursor: pointer; } .secondaryButton { - border: 1px solid var(--border, #d9d9d9); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 6px 14px; - background: var(--surface, #fff); + background: var(--dsw-alias-bg-layer-3); color: inherit; font: inherit; cursor: pointer; @@ -113,7 +83,7 @@ .dangerButton { border: none; background: none; - color: var(--text-danger, #c0392b); + color: var(--dsw-alias-state-error-primary); font: inherit; cursor: pointer; } @@ -126,9 +96,9 @@ } .editor { - border: 1px solid var(--border, #e6e6e6); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; - background: var(--surface-secondary, #f7f7f8); + background: var(--dsw-alias-bg-layer-2); padding: 14px 16px; display: flex; flex-direction: column; @@ -148,7 +118,7 @@ .editorRoute { font-size: 12px; - color: var(--text-tertiary, #999); + color: var(--dsw-alias-label-tertiary); } .field { @@ -163,14 +133,14 @@ gap: 10px; font-size: 12px; font-weight: 500; - color: var(--text-secondary, #555); + color: var(--dsw-alias-label-secondary); } .linkButton { border: none; background: none; padding: 0; - color: var(--text-tertiary, #888); + color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; text-decoration: underline; @@ -185,7 +155,7 @@ .advancedHint { margin: 0; font-size: 12px; - color: var(--text-tertiary, #999); + color: var(--dsw-alias-label-tertiary); } .editorActions { @@ -202,12 +172,12 @@ .addButton { align-self: flex-start; - border: 1px solid var(--border, #d9d9d9); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 8px 16px; font: inherit; font-size: 13px; - background: var(--surface, #fff); + background: var(--dsw-alias-bg-layer-3); color: inherit; cursor: pointer; } @@ -219,9 +189,9 @@ .addCard, .setupCard { - border: 1px solid var(--border, #e6e6e6); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; - background: var(--surface-secondary, #f7f7f8); + background: var(--dsw-alias-bg-layer-3); padding: 14px 16px; display: flex; flex-direction: column; @@ -237,7 +207,7 @@ } .customized { - border-top: 1px solid var(--border, #ececec); + border-top: 1px solid var(--dsw-alias-border-l2); padding-top: 10px; } @@ -245,7 +215,7 @@ cursor: pointer; font-size: 12px; font-weight: 500; - color: var(--text-secondary, #555); + color: var(--dsw-alias-label-secondary); list-style: revert; } @@ -259,25 +229,38 @@ .input { box-sizing: border-box; padding: 9px 12px; - border: 1px solid var(--border, #d9d9d9); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; font: inherit; font-size: 13px; - background: var(--surface, #fff); - color: inherit; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); } .input:focus { outline: none; - border-color: var(--accent-strong, #111); + border-color: var(--dsw-alias-brand-primary); } .input::placeholder { - color: var(--text-tertiary, #aaa); + color: var(--dsw-alias-label-dimmed); } .error { margin: 0; font-size: 12px; - color: var(--text-danger, #c0392b); + color: var(--dsw-alias-state-error-primary); +} + +.deleteDialog { + width: min(480px, 100%); +} + +.deleteConfirm:not(:disabled) { + border-color: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-state-error-primary); +} + +.deleteConfirm:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); } diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index d095acb86c..c206dbd864 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -4,13 +4,15 @@ * card at a time. A whole-section provider without a configured key (the * unconfigured DeepSeek posture) renders as its open setup card instead of a * row; the add flow is a card carrying the dormant-provider select. Every - * mutation writes through the wire; the page re-renders from the pushed - * invalidations or the post-apply reload. + * mutation writes through the wire, while a provider removal first requires + * confirmation; the page re-renders from pushed invalidations or the + * post-apply reload. */ import { useState } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { messageOf } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' @@ -114,6 +116,8 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const state = injected.useSnapshot(snapshot => snapshot) const [editing, setEditing] = useState(undefined) const [adding, setAdding] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(undefined) + const [deleting, setDeleting] = useState(false) const closeEditor = (changed: boolean): void => { setEditing(undefined) @@ -121,6 +125,26 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { if (changed) void controller.load() } + const closeDelete = (): void => { + if (deleting) return + setDeleteTarget(undefined) + } + + const confirmDelete = (): void => { + /* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */ + if (deleteTarget === undefined || deleting) return + setDeleting(true) + void removeProviderProfile(api, controller, deleteTarget) + .then((failure) => { + if (failure !== undefined) { + controller.fail(failure) + return + } + setDeleteTarget(undefined) + }) + .finally(() => { setDeleting(false) }) + } + if (state.status === 'idle') void controller.load() if (state.status === 'error') { /* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */ @@ -174,11 +198,6 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
  • {row.entry.displayName} - - {row.entry.active - ? {t('active')} - : {t('dormant')}} - @@ -276,6 +291,29 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { )}
    + + + + + )} + /> ) } diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 48431ddacf..c9491273bc 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -5,12 +5,15 @@ export const en = { nav: 'Models', title: 'Models', intro: 'Enter your API keys to use models from the following providers.', - active: 'Active', - dormant: 'Inactive', edit: 'Edit', remove: 'Delete', + deleteTitle: 'Delete model provider?', + deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.', + deleteConfirm: 'Delete provider', + deleting: 'Deleting provider…', add: 'Add provider', provider: 'Provider', + close: 'Close', cancel: 'Cancel', apply: 'Apply', applying: 'Applying…', @@ -42,12 +45,15 @@ export const zh: typeof en = { nav: '模型', title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', - active: '已启用', - dormant: '未启用', edit: '编辑', remove: '删除', + deleteTitle: '删除模型提供方?', + deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。', + deleteConfirm: '删除提供方', + deleting: '正在删除提供方…', add: '添加提供方', provider: '提供方', + close: '关闭', cancel: '取消', apply: '保存', applying: '保存中…', diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index eb34b162a0..6c64a93058 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -48,6 +48,7 @@ describe('ui-models apply', () => { expect(resolveSlotLabel(entry.options.label)).toBe('模型') const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)() expect(injected.t('nav')).toBe('模型') + expect(injected.t('deleteTitle')).toBe('删除模型提供方?') expect(typeof injected.controller.load).toBe('function') expect(typeof injected.useSnapshot).toBe('function') expect(injected.api).toBeDefined() @@ -73,8 +74,11 @@ describe('ui-models apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() b.locale.setLocale('en') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models') + const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected + expect(injected().t('deleteTitle')).toBe('Delete model provider?') b.locale.setLocale('zh') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型') + expect(injected().t('deleteTitle')).toBe('删除模型提供方?') }) it('locale change while the slot is undeclared stays a no-op', async () => { diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index a4f3734fbd..bf6eede131 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom /** Section, setup-card, and hand-written editor behavior over a scripted wire face. */ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from 'schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' @@ -152,10 +152,9 @@ describe('ModelsSection', () => { // DeepSeek has no configured credential and no stored apiKey → setup card. expect(screen.getByText('DeepSeek')).toBeTruthy() expect(screen.getByLabelText(en.keyInput)).toBeTruthy() - // Configured pi-ai profiles render as rows with liveness badges only. expect(screen.getByText('openai')).toBeTruthy() - expect(screen.getAllByText(en.active)).toHaveLength(1) - expect(screen.getByText(en.dormant)).toBeTruthy() + expect(screen.queryByText('Active')).toBeNull() + expect(screen.queryByText('Inactive')).toBeNull() expect(screen.getByText(`+ ${en.add}`)).toBeTruthy() }) @@ -471,10 +470,28 @@ describe('ModelsSection', () => { await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) }) }) - it('removes a user-added provider by unsetting its path', async () => { + it('requires confirmation before removing a user-added provider', async () => { const { replace, mutate } = await mountSection() fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) + expect(dialog.textContent).toContain(en.deleteDescription) + expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel })) + expect(mutate).not.toHaveBeenCalled() + fireEvent.click(within(dialog).getByRole('button', { name: en.cancel })) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(mutate).not.toHaveBeenCalled() + + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.close })) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(mutate).not.toHaveBeenCalled() + + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.deleteConfirm })) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() expect(replace).not.toHaveBeenCalled() expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -482,6 +499,28 @@ describe('ModelsSection', () => { }) }) + it('blocks duplicate deletion while the confirmed removal is pending', async () => { + let resolveRemoval!: (response: RpcResponse) => void + const mutate = vi.fn(() => new Promise>((resolve) => { + resolveRemoval = resolve + })) + await mountSection({ mutate }) + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) + const confirm = within(dialog).getByRole('button', { name: en.deleteConfirm }) + fireEvent.click(confirm) + fireEvent.click(confirm) + expect(mutate).toHaveBeenCalledOnce() + expect(confirm.disabled).toBe(true) + expect(within(dialog).getByRole('button', { name: en.cancel }).disabled).toBe(true) + expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm) + fireEvent.click(within(dialog).getByRole('button', { name: en.close })) + expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog) + expect(mutate).toHaveBeenCalledOnce() + await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) }) + await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() }) + }) + it('renders the load failure with a retry control', async () => { const face = scriptedFace() face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never @@ -589,6 +628,8 @@ describe('ModelsSection', () => { // would appear — rather than the row silently staying put. await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) }) fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.deleteConfirm })) await screen.findByText(`${en.loadFailed}: the host refused`) }) diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.spec.ts new file mode 100644 index 0000000000..478046454b --- /dev/null +++ b/packages/client/ui-models/tests/styles.spec.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8') + +describe('ModelsSection theme styles', () => { + it('uses the shared theme tokens without light-only fallbacks', () => { + expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/) + expect(css).toContain('background: var(--dsw-alias-bg-layer-3)') + expect(css).toContain('color: var(--dsw-alias-label-primary)') + }) +}) diff --git a/packages/client/ui-permission/README.i18n.yaml b/packages/client/ui-permission/README.i18n.yaml index 12fef93f39..736b325754 100644 --- a/packages/client/ui-permission/README.i18n.yaml +++ b/packages/client/ui-permission/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-permission/README.md -README.md: 3377a1c5907b67b065879b012923427685c106d6 -README.zh.md: 34cf6f72394632968ded1671a5ac0377e5c78cc6 +README.md: 742e82d767152073ab963dc74c0565d6e8f8e5c4 +README.zh.md: e4b39567e4e39d74fd4d527ed2fcfed8d5318a59 diff --git a/packages/client/ui-permission/README.md b/packages/client/ui-permission/README.md index 3377a1c590..742e82d767 100644 --- a/packages/client/ui-permission/README.md +++ b/packages/client/ui-permission/README.md @@ -2,13 +2,15 @@ English | [中文](README.zh.md) -Permission preset selection plugin, browser half: a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write` → `Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows no picker (a decoration never manufactures a catalog row). +Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. Its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding; a push invalidation refetches the descriptor. This value applies only when a later session is created; changing it does not switch the current session. Choosing Full access requires an explicit risk acknowledgement before the row writes it. + +The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write` → `Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row. The `/client` export surface is the plugin body (`apply`/`inject`). ## Model Experience -Indirectly, through the host `/permission` command the picker submits: a switch appends the whole-value knob events (`permission/preset`, `sandbox/mode`, `approval/policy`), which select the sandbox mode and approval policy later tool calls resolve. Picker interaction adds no prompt content. +Indirectly, through the permission facts written by its two surfaces: the Settings row causes a future session to start with whole-value knob events (`permission/preset`, `sandbox/mode`, `approval/policy`), while the `/permission` picker appends the same facts when it switches the current session; those events select the sandbox mode and approval policy later tool calls resolve, and picker interaction adds no prompt content. #### KV Cache effect @@ -16,4 +18,4 @@ No direct invalidation; the knob consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **No keyless snapshot exercises the picker yet** — the popup flow is covered by unit specs over fake faces; the assembled-transcript scenario rides the deferred approval/preset e2e work. +- **The Settings row is Web-only** — non-Web clients may still switch the current session through `/permission`, but do not receive this browser contribution. diff --git a/packages/client/ui-permission/README.zh.md b/packages/client/ui-permission/README.zh.md index 34cf6f7239..e4b39567e4 100644 --- a/packages/client/ui-permission/README.zh.md +++ b/packages/client/ui-permission/README.zh.md @@ -2,13 +2,15 @@ [English](README.md) | 中文 -权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-case 预设名渲染为 Title Case 标签(`workspace-write` → `Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合不显示选择框(装饰绝不无中生有目录行)。 +面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值仅在后续会话创建时生效;改变它不会切换当前会话。选择 Full access 时必须先显式确认风险,该行随后才会写入。 + +当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-case 预设名渲染为 Title Case 标签(`workspace-write` → `Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。 `/client` 导出面为插件本体(`apply`/`inject`)。 ## Model Experience -间接影响,经由选择框提交的 host `/permission` 命令:一次切换追加全量值旋钮事件(`permission/preset`、`sandbox/mode`、`approval/policy`),决定后续工具调用解析到的沙箱模式与审批策略。选择框交互本身不添加任何提示词内容。 +通过两个界面写入的权限事实间接影响:Settings 行使未来会话带着全量值旋钮事件(`permission/preset`、`sandbox/mode`、`approval/policy`)启动,而 `/permission` 选择框切换当前会话时会追加相同的事实;这些事件决定后续工具调用解析到的沙箱模式与审批策略,选择框交互本身不添加任何提示词内容。 #### KV Cache effect @@ -16,4 +18,4 @@ ## Known Limitations and Deferred Work -- **尚无无密钥快照覆盖选择框** —— popup 流程由基于 fake face 的单元 spec 覆盖;组装态转写场景随延后的审批/预设 e2e 工作一并补齐。 +- **Settings 行仅在 Web 中可用**:非 Web 客户端仍可通过 `/permission` 切换当前会话,但不会获得这项浏览器贡献。 diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index ee2d789500..33813d5af1 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-permission", - "description": "Permission preset selection: the /permission popupSelect over the permissions projection and the host /permission command", + "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", "version": "0.0.1", "private": true, "type": "module", @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-command" @@ -36,22 +37,34 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-schema-form": "^0.0.1", "@deepseek-ai/dsh-client-ui-command": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-permission": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-permission/src/client/PermissionRow.module.css b/packages/client/ui-permission/src/client/PermissionRow.module.css new file mode 100644 index 0000000000..b7a11bed6a --- /dev/null +++ b/packages/client/ui-permission/src/client/PermissionRow.module.css @@ -0,0 +1,60 @@ +/* Permission row: title/description plus the preset selector pill. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.selector:disabled { + cursor: default; +} + +.chevron { + flex: none; +} diff --git a/packages/client/ui-permission/src/client/PermissionRow.tsx b/packages/client/ui-permission/src/client/PermissionRow.tsx new file mode 100644 index 0000000000..ae8c8bafe7 --- /dev/null +++ b/packages/client/ui-permission/src/client/PermissionRow.tsx @@ -0,0 +1,133 @@ +/** + * Permission preference row: the default preset for subsequently created + * sessions. Current-session switches remain on the composer `/permission` + * control. + */ + +import { useEffect, useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { + IconChevronDownOutline14, Menu, RiskConfirmation, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PermissionSettingsState } from './settings-store.ts' +import type { PermissionSettingsKey } from './locales.ts' +import { FULL_ACCESS_PRESET } from './presentation.ts' +import css from './PermissionRow.module.css' + +/** Registration-side business face for the host-backed preference. */ +export interface PermissionRowInjected { + hooks: { + /** Permission settings snapshot bound by the renderer as usePermission. */ + permission: SnapshotStore + } + /** Load the descriptor when the row first renders. */ + load: () => Promise + /** Persist one advertised preset. */ + select: (preset: string) => Promise +} + +/** Full component props. */ +export type PermissionRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'settings.permission'> + & InjectFace + +/** + * Render the new-session Permission default selector. + * @param props - composed slot props. + * @returns the row, or null when the host does not expose permission settings. + */ +export function PermissionRow({ load, select, usePermission, t }: PermissionRowProps) { + const state = usePermission(snapshot => snapshot) + const [open, setOpen] = useState(false) + const [confirmingFullAccess, setConfirmingFullAccess] = useState(false) + const [acknowledged, setAcknowledged] = useState(false) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (state.writable && state.status !== 'unavailable') return + setOpen(false) + setAcknowledged(false) + setConfirmingFullAccess(false) + }, [state.status, state.writable]) + + if (state.status === 'unavailable') return null + const selected = state.options.find(option => option.id === state.currentValue) + const busy = state.status === 'loading' || state.status === 'saving' || confirmingFullAccess + const label = selected?.label + ?? (busy ? t('loading') : t('unavailable')) + const description: string = state.error ?? t('description') + + return ( + <> +
    +
    +
    {t('title')}
    +
    {description}
    +
    + { setOpen(false) }} + items={state.options.map(option => ({ id: option.id, label: option.label }))} + selectedId={state.currentValue} + onSelect={(id) => { + setOpen(false) + if (id === state.currentValue) return + if (id === FULL_ACCESS_PRESET) { + setAcknowledged(false) + setConfirmingFullAccess(true) + return + } + void select(id) + }} + align="end" + portal + anchor={( + + )} + /> +
    + { + setAcknowledged(false) + setConfirmingFullAccess(false) + }} + onConfirm={() => { + setAcknowledged(false) + setConfirmingFullAccess(false) + void select(FULL_ACCESS_PRESET) + }} + /> + + ) +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Permission row copy. */ + 'settings.permission': PermissionSettingsKey + } +} diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index d5561afe32..d6cd286959 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -10,18 +10,37 @@ * write through one path and the pushed projection frame is the one * confirmation. The Full access row carries the same explicit risk gate as * the composer chip; the shared popup shell owns the modal mechanics. + * The General-settings row separately writes the default preset for sessions + * created later through the host Settings API. */ +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' 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 { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' +import { PermissionRow } from './PermissionRow.tsx' +import type { PermissionRowInjected } from './PermissionRow.tsx' +import { + accessEn, accessZh, en, zh, +} from './locales.ts' +import { + displayPermissionPreset, FULL_ACCESS_PRESET, +} from './presentation.ts' +import { + PERMISSION_SETTINGS_NS, PermissionSettingsController, refreshPermissionIfLoaded, +} from './settings-store.ts' + +export type { PermissionRowInjected, PermissionRowProps } from './PermissionRow.tsx' +export type { + PermissionDefaultOption, PermissionSettingsState, +} from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['command', 'sessions', 'locale'] +export const inject = ['command', 'sessions', 'slots', 'locale', 'connection'] -const FULL_ACCESS = 'danger-full-access' const ACCESS_NS = 'permission.access' /** Read one session's current permissions projection value (undefined = capability absent). */ @@ -29,28 +48,16 @@ function selectOf(session: SessionFace | undefined): PermissionSelect | undefine return session?.projections.faceOf('permissions').getSnapshot() as PermissionSelect | undefined } -/** - * Display transform twin of the composer chip's (ui-conversation - * PermissionSelect): kebab-case machine names render as title-case labels - * (`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 - return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') -} - /** Flatten the projection select into popup rows; `custom` is display state, never a target. */ function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectOption[] { return value.options .filter(option => option.value !== 'custom') .map(option => ({ id: option.value, - label: option.value === FULL_ACCESS ? 'Full access' : displayName(option.name), + label: displayPermissionPreset(option.value, option.name), ...(option.description !== undefined ? { detail: option.description } : {}), ...(option.value === value.currentValue ? { active: true } : {}), - ...(option.value === FULL_ACCESS + ...(option.value === FULL_ACCESS_PRESET ? { confirmation: { title: t('confirm.title'), @@ -78,18 +85,18 @@ export function apply(ctx: ClientContext): void { 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', + 'confirm.title': accessZh['confirm.title'], + 'confirm.description': accessZh['confirm.description'], + 'confirm.acknowledge': accessZh['confirm.acknowledge'], + 'confirm.cancel': accessZh['confirm.cancel'], + 'confirm.enable': accessZh['confirm.enable'], }), 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', + 'confirm.title': accessEn['confirm.title'], + 'confirm.description': accessEn['confirm.description'], + 'confirm.acknowledge': accessEn['confirm.acknowledge'], + 'confirm.cancel': accessEn['confirm.cancel'], + 'confirm.enable': accessEn['confirm.enable'], }), ] return () => { for (const dispose of disposers) dispose() } @@ -98,6 +105,46 @@ export function apply(ctx: ClientContext): void { const t = ctx.locale.bind(ACCESS_NS) const sessionFor = (session: ClientSessionContext): SessionFace | undefined => sessions.binding(session.sessionId)?.session + + ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries') + + const connection = ctx.get('connection') as ConnectionHandle + const controller = new PermissionSettingsController(connection.api) + const load = (): Promise => controller.load() + const select = (preset: string): Promise => controller.select(preset) + const injected = (): PermissionRowInjected => ({ + hooks: { permission: controller.store }, + load, + select, + }) + + ctx.effect(() => { + const refresh = (ns?: string): void => { + if (ns !== undefined && ns !== PERMISSION_SETTINGS_NS) return + refreshPermissionIfLoaded(controller) + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + return () => { + controller.dispose() + for (const dispose of disposers) dispose() + } + }, 'ui-permission: settings invalidations') + + ctx.effect(() => { + const row = deferRegistration(ctx.slots, 'settings.general.item', PermissionRow, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'permission', + order: -20, + locale: 'settings.permission', + inject: injected, + }, PermissionRow)) + return () => { row.dispose() } + }, 'ui-permission: General settings row') + ctx.effect(() => command.decorate({ name: 'permission', // The picker exists exactly while the projection does: a permission-less diff --git a/packages/client/ui-permission/src/client/locales.ts b/packages/client/ui-permission/src/client/locales.ts new file mode 100644 index 0000000000..3920a7a1ff --- /dev/null +++ b/packages/client/ui-permission/src/client/locales.ts @@ -0,0 +1,51 @@ +/** `settings.permission` namespace dictionaries (the Permission row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'title': '权限', + 'description': '选择新会话的默认权限模式', + 'loading': '加载中', + 'unavailable': '不可用', + 'confirm.title': '确认启用 Full access?', + 'confirm.description': '启用 Full access 后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。', + 'confirm.acknowledge': '我已了解风险,并愿意继续', + 'confirm.cancel': '取消', + 'confirm.enable': '启用 Full access', +} satisfies Record + +/** The settings.permission namespace key union. */ +export type PermissionSettingsKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'title': 'Permission', + 'description': 'Choose the default permission mode for new sessions', + 'loading': 'Loading', + 'unavailable': 'Unavailable', + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access lets new sessions reduce confirmation steps and perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust subsequent tasks.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', +} satisfies Record + +/** Simplified Chinese dictionary for the current-session popup gate. */ +export const accessZh = { + 'confirm.title': '确认启用 Full access?', + 'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', + 'confirm.acknowledge': '我已了解风险,并愿意继续', + 'confirm.cancel': '取消', + 'confirm.enable': '启用 Full access', +} satisfies Record + +/** Current-session popup-gate key union. */ +export type PermissionAccessKey = keyof typeof accessZh + +/** English dictionary for the current-session popup gate. */ +export const accessEn = { + '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', +} satisfies Record diff --git a/packages/client/ui-permission/src/client/presentation.ts b/packages/client/ui-permission/src/client/presentation.ts new file mode 100644 index 0000000000..ab1b9a5345 --- /dev/null +++ b/packages/client/ui-permission/src/client/presentation.ts @@ -0,0 +1,22 @@ +/** Machine value of the preset that requires an explicit GUI risk gate. */ +export const FULL_ACCESS_PRESET = 'danger-full-access' + +/** + * Convert conventional kebab-case preset names into user-facing title case. + * @param name - host-supplied preset label or key. + * @returns the title-cased conventional key, or a non-kebab label unchanged. + */ +export function displayPresetName(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(' ') +} + +/** + * Render a permission preset under its product label. + * @param value - preset machine value. + * @param name - host-supplied preset name. + * @returns the Full access product label or the conventional display name. + */ +export function displayPermissionPreset(value: string, name: string): string { + return value === FULL_ACCESS_PRESET ? 'Full access' : displayPresetName(name) +} diff --git a/packages/client/ui-permission/src/client/settings-store.ts b/packages/client/ui-permission/src/client/settings-store.ts new file mode 100644 index 0000000000..7d4bf864c1 --- /dev/null +++ b/packages/client/ui-permission/src/client/settings-store.ts @@ -0,0 +1,191 @@ +/** + * Permission default-settings controller. The host descriptor supplies the + * current value and the dynamic preset enum; writes target only + * `defaultPreset` and carry the descriptor revision. + */ + +import type { + IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { + createSnapshotStore, type SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + nodeAtPath, rehydrateSchema, type SchemaNode, +} from '@deepseek-ai/dsh-client-schema-form' +import { displayPermissionPreset } from './presentation.ts' + +/** Permission's settings namespace on the host wire. */ +export const PERMISSION_SETTINGS_NS = 'permission' + +/** One selectable new-session default. */ +export interface PermissionDefaultOption { + /** Preset key written to Settings. */ + id: string + /** Host-supplied label or a title-cased preset key. */ + label: string +} + +/** Permission settings-row snapshot. */ +export interface PermissionSettingsState { + status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error' + error: string | null + writable: boolean + currentValue: string + options: readonly PermissionDefaultOption[] + revision: number +} + +interface ConstChoice { + type: string + value?: unknown + meta?: { description?: unknown } +} + +/** + * Read the dynamic preset enum encoded by the host's `defaultPreset` schema. + * @param view - permission namespace descriptor. + * @returns current value and selectable options. + */ +export function permissionDefaultOf(view: SettingsNamespaceView): { + currentValue: string + options: PermissionDefaultOption[] +} { + const value = (view.value as { defaultPreset?: unknown } | null)?.defaultPreset + if (typeof value !== 'string') throw new Error('permission settings has no defaultPreset value') + const node = nodeAtPath(rehydrateSchema(view.schema), ['defaultPreset']) + if (node === undefined) throw new Error('permission settings schema has no defaultPreset field') + const rawChoices = node.type === 'union' + ? (node.list as SchemaNode[] | undefined) ?? [] + : [node] + const options = rawChoices.flatMap((candidate) => { + const choice = candidate as unknown as ConstChoice + if (choice.type !== 'const' || typeof choice.value !== 'string') return [] + const described = choice.meta?.description + return [{ + id: choice.value, + label: typeof described === 'string' && described.length > 0 + ? displayPermissionPreset(choice.value, described) + : displayPermissionPreset(choice.value, choice.value), + }] + }) + if (options.length === 0 || !options.some(option => option.id === value)) { + throw new Error('permission settings schema does not advertise its current preset') + } + return { currentValue: value, options } +} + +/** Controller joining Settings reads, writes, and pushed invalidations. */ +export class PermissionSettingsController { + /** Row snapshot consumed through a bound selector hook. */ + readonly store: SnapshotStore = createSnapshotStore({ + status: 'idle', + error: null, + writable: false, + currentValue: '', + options: [], + revision: 0, + }) + + private generation = 0 + private view: SettingsNamespaceView | undefined + + /** @param api - Settings wire face. */ + constructor(private readonly api: Pick) {} + + /** + * Refresh the permission descriptor. Latest request wins. + * @returns nothing; {@link store} carries success or failure. + */ + async load(): Promise { + const generation = ++this.generation + this.store.update((state) => { + state.status = 'loading' + state.error = null + }) + try { + const response = await this.api.settings.describe({}) + if (!response.result.ok) throw new Error(response.result.error.message) + if (generation !== this.generation) return + const view = response.result.value.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS) + if (view === undefined) { + this.view = undefined + this.store.update((state) => { + state.status = 'unavailable' + state.writable = false + state.currentValue = '' + state.options = [] + }) + return + } + this.accept(view, response.result.value.writable) + } catch (error) { + if (generation !== this.generation) return + this.fail(error) + } + } + + /** + * Persist one preset as the default for subsequently created sessions. + * @param preset - advertised preset key. + * @returns nothing; {@link store} carries success or failure. + */ + async select(preset: string): Promise { + const view = this.view + const state = this.store.getSnapshot() + if (view === undefined || !state.writable) return + const generation = ++this.generation + this.store.update((draft) => { + draft.status = 'saving' + draft.error = null + }) + try { + const response = await this.api.settings.mutate({ + ns: PERMISSION_SETTINGS_NS, + ops: [{ op: 'set', path: ['defaultPreset'], value: preset }], + expectedRevision: view.revision, + }) + if (generation !== this.generation) return + if (!response.result.ok) throw new Error(response.result.error.message) + this.accept(response.result.value, true) + } catch (error) { + if (generation !== this.generation) return + this.fail(error) + } + } + + /** Stop in-flight responses from publishing after plugin disposal. */ + dispose(): void { + this.generation += 1 + this.view = undefined + } + + private accept(view: SettingsNamespaceView, writable: boolean): void { + const resolved = permissionDefaultOf(view) + this.view = view + this.store.update((state) => { + state.status = 'ready' + state.error = null + state.writable = writable + state.currentValue = resolved.currentValue + state.options = resolved.options + state.revision = view.revision + }) + } + + private fail(error: unknown): void { + this.store.update((state) => { + state.status = 'error' + state.error = error instanceof Error ? error.message : String(error) + }) + } +} + +/** + * Refetch only after the row has opened once. + * @param controller - permission settings controller. + */ +export function refreshPermissionIfLoaded(controller: PermissionSettingsController): void { + if (controller.store.getSnapshot().status === 'idle') return + void controller.load() +} diff --git a/packages/client/ui-permission/src/css-modules.d.ts b/packages/client/ui-permission/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-permission/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-permission/src/index.ts b/packages/client/ui-permission/src/index.ts index 5359562972..5c28cd69b2 100644 --- a/packages/client/ui-permission/src/index.ts +++ b/packages/client/ui-permission/src/index.ts @@ -1,8 +1,8 @@ /** - * Permission preset selection plugin, node half. Pure UI plugin: the empty - * apply exists so the plugin appears in the host cordis.yml / Loader; the - * browser half ships via exports["./client"], discovered through the - * package.json dshClient declaration. + * Permission surfaces plugin, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half ships the + * new-session Settings row and current-session command picker through + * exports["./client"], discovered from the package.json dshClient declaration. */ /** Host plugin body — no host-side behavior for this surface plugin. */ diff --git a/packages/client/ui-permission/src/invariant.ts b/packages/client/ui-permission/src/invariant.ts index c0fd33a80b..1c3f7d6500 100644 --- a/packages/client/ui-permission/src/invariant.ts +++ b/packages/client/ui-permission/src/invariant.ts @@ -15,9 +15,9 @@ export const name = 'client-ui-permission-invariant' export const inject = ['invariants'] /** - * No runtime invariant: a single command contribution registration whose disposal is - * proven by the HMR-safety spec — it emits no cordis events and owns no - * cross-plugin mutable state. + * No runtime invariant: the command and slot contribution lifecycles are + * proven by the HMR-safety spec, while the browser-only Settings controller + * owns no host events or cross-plugin mutable state. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index f8fde6d10d..fea56a413a 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -5,14 +5,20 @@ * the current value active and `custom` excluded; availability follows the * projection key's presence; a pick submits the /permission line through * Session.command and surfaces rejection/unmatched as thrown errors; fiber - * disposal removes the contribution (HMR safety). + * disposal removes the contribution (HMR safety). The same plugin registers + * its Settings row and invalidates that row on host settings changes. */ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' +import { + PermissionRow, type PermissionRowInjected, +} from '../src/client/PermissionRow.tsx' import { apply, inject } from '../src/client/index.ts' +import { accessEn } from '../src/client/locales.ts' const sid = (k: string): SessionId => k as SessionId @@ -27,6 +33,27 @@ const SELECT: PermissionSelect = { async function bench() { const ctx = new Context() + await ctx.plugin(SlotsService) + const locale = new LocaleService(ctx) + locale.setLocale('en') + ctx.provide('locale', locale) + ctx.slots.register({ + name: 'root', + children: { + 'settings.general.item': { kind: 'list', scope: 'root' }, + }, + } as never, () => null) + ctx.provide('connection', { + api: { + settings: { + describe: () => Promise.resolve({ + rpcId: 'describe', + result: { ok: true as const, value: { writable: true, namespaces: [] } }, + }), + mutate: () => Promise.reject(new Error('settings mutation is not exercised')), + }, + }, + } as never) let decoration: CommandDecoration | undefined ctx.provide('command', { decorate(c: CommandDecoration) { @@ -54,23 +81,14 @@ 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 { ctx, fiber, values, commands, setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r }, decoration: () => decoration, + permissionRow: () => ctx.slots.entries('settings.general.item') + .find(entry => entry.component === PermissionRow), } } @@ -80,6 +98,14 @@ describe('ui-permission browser plugin', () => { const c = b.decoration()! expect(c.name).toBe('permission') expect(c.ui.kind).toBe('popupSelect') + const row = b.permissionRow()! + expect(row.options).toEqual({ id: 'permission', order: -20 }) + const injected = row.inject?.() as PermissionRowInjected | undefined + expect(injected?.hooks.permission).toBeDefined() + expect(typeof injected?.load).toBe('function') + expect(typeof injected?.select).toBe('function') + await injected!.load() + await injected!.select('read-only') }) it('availability follows the projection key; options mark the current value active and exclude custom', async () => { @@ -100,7 +126,7 @@ describe('ui-permission browser plugin', () => { 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.', + description: accessEn['confirm.description'], acknowledgeLabel: 'I understand the risks and want to continue', cancelLabel: 'Cancel', confirmLabel: 'Enable Full access', @@ -132,7 +158,11 @@ describe('ui-permission browser plugin', () => { it('disposal removes the decoration (HMR safety)', async () => { const b = await bench() expect(b.decoration()).toBeDefined() + b.ctx.emit('settings/changed', 'another') + b.ctx.emit('settings/changed', 'permission') + b.ctx.emit('connection/reset') await b.fiber.dispose() expect(b.decoration()).toBeUndefined() + expect(b.permissionRow()).toBeUndefined() }) }) diff --git a/packages/client/ui-permission/tests/permission-row.spec.tsx b/packages/client/ui-permission/tests/permission-row.spec.tsx new file mode 100644 index 0000000000..f74e6ae2ad --- /dev/null +++ b/packages/client/ui-permission/tests/permission-row.spec.tsx @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' +import { en } from '../src/client/locales.ts' +import { PermissionSettingsController } from '../src/client/settings-store.ts' + +afterEach(cleanup) + +const SCHEMA = { + uid: 5, + refs: { + 1: { type: 'const', value: 'read-only' }, + 2: { type: 'const', value: 'workspace-write' }, + 3: { type: 'const', value: 'danger-full-access' }, + 4: { type: 'union', list: [1, 2, 3] }, + 5: { type: 'object', dict: { defaultPreset: 4 } }, + }, +} + +function view(defaultPreset: string, revision = 0): SettingsNamespaceView { + return { + ns: 'permission', + schema: SCHEMA, + value: { defaultPreset }, + base: { defaultPreset: 'read-only' }, + applies: 'live', + secrets: [], + revision, + } +} + +function ok(value: T) { + return { rpcId: 'test', result: { ok: true as const, value } } +} + +const dictionary: Record = en +const t: PermissionRowProps['t'] = key => dictionary[key] ?? key +const runtime = { + useSessions: (() => { throw new Error('unused') }) as never, + useWorkspaces: (() => { throw new Error('unused') }) as never, +} + +function mount(controller: PermissionSettingsController) { + return render( + controller.load()} + select={preset => controller.select(preset)} + usePermission={bindSnapshotSelector(controller.store)} + t={t} + />, + ) +} + +describe('PermissionRow', () => { + it('loads the descriptor, opens the menu, and selects a new default', async () => { + const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1)))) + const controller = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate, + } as never, + }) + mount(controller) + const button = await screen.findByRole('button', { name: 'Read Only' }) + expect(button.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(button) + expect(button.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) + fireEvent.click(button) + fireEvent.click(button) + expect(button.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Read Only' })) + expect(mutate).not.toHaveBeenCalled() + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) + await screen.findByRole('button', { name: 'Workspace Write' }) + expect(mutate).toHaveBeenCalledOnce() + }) + + it('requires explicit acknowledgement before saving Full access', async () => { + const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1)))) + const controller = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate, + } as never, + }) + mount(controller) + fireEvent.click(await screen.findByRole('button', { name: 'Read Only' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' })) + expect(mutate).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog', { name: 'Enable Full access?' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Read Only' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' })) + const dialog = screen.getByRole('dialog', { name: 'Enable Full access?' }) + const enable = screen.getByRole('button', { name: 'Enable Full access' }) + expect((enable as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(enable) + await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + expect(dialog.isConnected).toBe(false) + }) + + it('hides an unavailable namespace and disables a read-only provider', async () => { + const absent = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [] })), + mutate: vi.fn(), + } as never, + }) + const rendered = mount(absent) + await waitFor(() => { expect(rendered.container.textContent).toBe('') }) + rendered.unmount() + + const readonly = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: false, namespaces: [view('read-only')] })), + mutate: vi.fn(), + } as never, + }) + mount(readonly) + expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true) + }) + + it('shows loading and a contained write error', async () => { + const describe = Promise.withResolvers>>() + const controller = new PermissionSettingsController({ + settings: { + describe: () => describe.promise, + mutate: () => Promise.resolve({ + rpcId: 'test', + result: { + ok: false as const, + error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} }, + }, + }), + } as never, + }) + mount(controller) + expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true) + describe.resolve(ok({ writable: true, namespaces: [view('read-only')] })) + const button = await screen.findByRole('button', { name: 'Read Only' }) + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) + expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere') + }) +}) diff --git a/packages/client/ui-permission/tests/settings-store.spec.ts b/packages/client/ui-permission/tests/settings-store.spec.ts new file mode 100644 index 0000000000..74edb838b0 --- /dev/null +++ b/packages/client/ui-permission/tests/settings-store.spec.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + PermissionSettingsController, permissionDefaultOf, refreshPermissionIfLoaded, +} from '../src/client/settings-store.ts' + +const SCHEMA = { + uid: 6, + refs: { + 1: { type: 'const', value: 'read-only' }, + 2: { type: 'const', meta: { description: 'Workspace' }, value: 'workspace-write' }, + 3: { type: 'union', list: [1, 2] }, + 6: { type: 'object', dict: { defaultPreset: 3 } }, + }, +} + +function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView { + return { + ns: 'permission', + schema, + value: { defaultPreset }, + base: { defaultPreset: 'read-only' }, + applies: 'live', + secrets: [], + revision, + } +} + +function ok(value: T) { + return { rpcId: 'test', result: { ok: true as const, value } } +} + +describe('permission settings store', () => { + it('derives dynamic options and host labels from the descriptor schema', () => { + expect(permissionDefaultOf(view('read-only'))).toEqual({ + currentValue: 'read-only', + options: [ + { id: 'read-only', label: 'Read Only' }, + { id: 'workspace-write', label: 'Workspace' }, + ], + }) + const single = { + uid: 2, + refs: { + 1: { type: 'const', meta: { description: '' }, value: 'read-only' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + } + expect(permissionDefaultOf(view('read-only', 0, single))).toEqual({ + currentValue: 'read-only', + options: [{ id: 'read-only', label: 'Read Only' }], + }) + const undescribed = { + uid: 2, + refs: { + 1: { type: 'const', meta: { description: 7 }, value: 'read-only' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + } + expect(permissionDefaultOf(view('read-only', 0, undescribed)).options) + .toEqual([{ id: 'read-only', label: 'Read Only' }]) + }) + + it('rejects malformed values and dynamic enums at the wire boundary', () => { + expect(() => permissionDefaultOf({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 1, refs: { 1: { type: 'object', dict: {} } }, + }))).toThrow(/no defaultPreset field/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 2, + refs: { + 1: { type: 'union' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + }))).toThrow(/does not advertise/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 4, + refs: { + 1: { type: 'string' }, + 2: { type: 'const', value: 1 }, + 3: { type: 'union', list: [1, 2] }, + 4: { type: 'object', dict: { defaultPreset: 3 } }, + }, + }))).toThrow(/does not advertise/) + expect(() => permissionDefaultOf(view('missing'))).toThrow(/does not advertise/) + }) + + it('loads and writes defaultPreset with optimistic concurrency', async () => { + const describe = vi.fn(() => Promise.resolve(ok({ + writable: true, + namespaces: [view('read-only', 4)], + }))) + const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5)))) + const controller = new PermissionSettingsController({ + settings: { describe, mutate } as never, + }) + await controller.load() + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', + writable: true, + currentValue: 'read-only', + revision: 4, + }) + await controller.select('workspace-write') + expect(mutate).toHaveBeenCalledWith({ + ns: 'permission', + ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], + expectedRevision: 4, + }) + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', + currentValue: 'workspace-write', + revision: 5, + }) + }) + + it('hides the row when the namespace is absent and contains write failures', async () => { + const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [] }))) + const controller = new PermissionSettingsController({ + settings: { describe, mutate: vi.fn() } as never, + }) + await controller.load() + expect(controller.store.getSnapshot().status).toBe('unavailable') + + const failing = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate: () => Promise.resolve({ + rpcId: 'test', + result: { + ok: false as const, + error: { code: 'settings-conflict', message: 'stale', details: {} }, + }, + }), + } as never, + }) + await failing.load() + await failing.select('workspace-write') + expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'stale' }) + }) + + it('contains read failures, no-ops without a writable view, and ignores stale responses', async () => { + const first = Promise.withResolvers>>() + const describe = vi.fn() + .mockImplementationOnce(() => first.promise) + .mockResolvedValueOnce(ok({ writable: false, namespaces: [view('read-only', 2)] })) + const mutate = vi.fn() + const controller = new PermissionSettingsController({ + settings: { describe, mutate } as never, + }) + const stale = controller.load() + await controller.load() + first.resolve(ok({ writable: true, namespaces: [view('workspace-write', 1)] })) + await stale + expect(controller.store.getSnapshot()).toMatchObject({ + currentValue: 'read-only', + writable: false, + revision: 2, + }) + await controller.select('workspace-write') + expect(mutate).not.toHaveBeenCalled() + + const rejected = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve({ + rpcId: 'test', + result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } }, + }), + mutate, + } as never, + }) + await rejected.select('workspace-write') + await rejected.load() + expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' }) + + const thrown = new PermissionSettingsController({ + settings: { + // Promise consumers must contain unknown rejection values from a + // transport implementation, including non-Error legacy clients. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + describe: () => Promise.reject('disconnected'), + mutate, + } as never, + }) + await thrown.load() + expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) + }) + + it('disposal suppresses in-flight reads and writes, and loaded invalidations refetch', async () => { + const read = Promise.withResolvers>>() + const describe = vi.fn(() => read.promise) + const idle = new PermissionSettingsController({ settings: { describe, mutate: vi.fn() } as never }) + refreshPermissionIfLoaded(idle) + expect(describe).not.toHaveBeenCalled() + const loading = idle.load() + idle.dispose() + read.resolve(ok({ writable: true, namespaces: [view('read-only')] })) + await loading + expect(idle.store.getSnapshot().status).toBe('loading') + + const rejectedRead = Promise.withResolvers>>() + const disposedRead = new PermissionSettingsController({ + settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never, + }) + const reading = disposedRead.load() + disposedRead.dispose() + rejectedRead.reject(new Error('late read')) + await reading + expect(disposedRead.store.getSnapshot().status).toBe('loading') + + const mutation = Promise.withResolvers>>() + const activeDescribe = vi.fn(() => Promise.resolve(ok({ + writable: true, + namespaces: [view('read-only')], + }))) + const active = new PermissionSettingsController({ + settings: { + describe: activeDescribe, + mutate: () => mutation.promise, + } as never, + }) + await active.load() + refreshPermissionIfLoaded(active) + await vi.waitFor(() => { expect(activeDescribe).toHaveBeenCalledTimes(2) }) + const saving = active.select('workspace-write') + active.dispose() + mutation.resolve(ok(view('workspace-write', 1))) + await saving + expect(active.store.getSnapshot().status).toBe('saving') + + const rejectedMutation = Promise.withResolvers>>() + const disposedWrite = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate: () => rejectedMutation.promise, + } as never, + }) + await disposedWrite.load() + const writing = disposedWrite.select('workspace-write') + disposedWrite.dispose() + rejectedMutation.reject(new Error('late write')) + await writing + expect(disposedWrite.store.getSnapshot().status).toBe('saving') + }) +}) diff --git a/packages/client/ui-permission/tsconfig.json b/packages/client/ui-permission/tsconfig.json index b66ce746b2..32f84d6a2d 100644 --- a/packages/client/ui-permission/tsconfig.json +++ b/packages/client/ui-permission/tsconfig.json @@ -8,18 +8,36 @@ "src" ], "references": [ + { + "path": "../connection" + }, + { + "path": "../locale" + }, { "path": "../../../vendor/cordis" }, { "path": "../runtime" }, + { + "path": "../schema-form" + }, { "path": "../ui-command" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slash" }, + { + "path": "../ui-slots" + }, + { + "path": "../web-react" + }, { "path": "../../ui/permission" }, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index c665d9a754..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: 5406d501eade2881491b3d157edddaa03f235a35 -README.zh.md: 18c7cefa7cdd631405f34f18e7c4c3369c65bc88 +README.md: 58be01d56a85c66a144df3f8054840961e987403 +README.zh.md: 2efbec77e64d664553e93b5a8f8dcd2ec7fce49e diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 5406d501ea..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), TerminalBlock, and WebBlock. 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,10 @@ 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). diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 18c7cefa7c..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 与 WebBlock。契约: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,10 @@ `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)。 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/index.ts b/packages/client/ui-primitives/src/index.ts index 649e811579..39b40bf680 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -24,6 +24,8 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx' +export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx' +export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx' export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx' export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' diff --git a/packages/client/ui-primitives/tests/diff-block.spec.tsx b/packages/client/ui-primitives/tests/diff-block.spec.tsx new file mode 100644 index 0000000000..bb4a2fdac3 --- /dev/null +++ b/packages/client/ui-primitives/tests/diff-block.spec.tsx @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +// DiffBlock: the per-file hunk rows (path header, removed block, added block), +// the same-file second-hunk gap separator, the `+A -R · N file(s)` footer and +// its singular/plural, the head/tail height cap and its expand control, the +// empty-diffs null render, and the copy control writing the prefixed diff text +// on both the accepted and the refused clipboard paths. writeClipboard's own +// return contract is pinned in terminal-block.spec.tsx (the shared seam), so +// only its DOM consequence is asserted here. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts' + +afterEach(cleanup) + +beforeEach(() => { + vi.useRealTimers() +}) + +/** The rendered body rows, one string per visible line (CSS-module class prefix). */ +function bodyRows(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class*="_line_"]')].map(row => row.textContent ?? '') +} + +/** Only the changed rows (add/del), excluding the path header and gap chrome. */ +function changeRows(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class*="_del_"], [class*="_add_"]')].map(row => row.textContent ?? '') +} + +/** `count` numbered added lines as one hunk's newText. */ +function added(count: number): string { + return Array.from({ length: count }, (_v, i) => `line ${i + 1}`).join('\n') +} + +describe('DiffBlock structure', () => { + it('renders a create as a path header and an added block (no removed side)', () => { + const diffs: DiffHunk[] = [{ path: 'notes/new.txt', oldText: null, newText: 'hello\nworld' }] + const { container } = render() + expect(screen.getByText('notes/new.txt')).toBeTruthy() + // No removed rows: both change lines are added. + expect(changeRows(container)).toEqual(['hello', 'world']) + expect(container.querySelectorAll('[class*="_del_"]').length).toBe(0) + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(2) + }) + + it('renders an edit as a removed block above an added block', () => { + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'old', newText: 'new' }] + const { container } = render() + expect(container.querySelectorAll('[class*="_del_"]').length).toBe(1) + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(1) + expect(changeRows(container)).toEqual(['old', 'new']) + }) + + it('opens a same-file second hunk with a gap instead of repeating the path', () => { + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: 'x', newText: 'y' }, + { path: 'a.ts', oldText: 'p', newText: 'q' }, + ] + const { container } = render() + // One path header, one gap row. + expect(container.querySelectorAll('[class*="_path_"]').length).toBe(1) + expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(1) + }) + + it('opens a new file with its own path header', () => { + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: 'x', newText: 'y' }, + { path: 'b.ts', oldText: 'p', newText: 'q' }, + ] + const { container } = render() + expect(container.querySelectorAll('[class*="_path_"]').length).toBe(2) + expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(0) + }) + + it('renders nothing for empty diffs', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('treats a trailing newline as a terminator, not an extra blank line', () => { + // A create whose newText ends in a newline is one added line, not two, and + // the footer counts one — the phantom `+ ` empty line the naive split drew. + const { container } = render() + expect(changeRows(container)).toEqual(['hello']) + expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy() + }) + + it('renders a full deletion as removed-only with no phantom added line', () => { + // newText '' is zero added lines: an empty string must contribute nothing. + const { container } = render() + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0) + expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy() + }) + + it('keeps a genuine interior blank line', () => { + const { container } = render() + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3) + }) +}) + +describe('DiffBlock footer', () => { + it('counts added and removed lines and one file', () => { + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }] + render() + expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy() + }) + + it('pluralizes the distinct-file count', () => { + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: null, newText: 'x' }, + { path: 'b.ts', oldText: null, newText: 'y' }, + ] + render() + expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy() + }) +}) + +describe('DiffBlock height cap', () => { + it('shows head and tail with an expand control past the cap, then all lines expanded', () => { + // One added line over the default cap forces the collapse. + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(DEFAULT_DIFF_MAX_LINES) }] + // The path header counts as a row, so a body of maxLines added lines plus + // the header is one over the cap. + const { container } = render() + const toggle = screen.getByRole('button', { name: /展开其余/ }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + // Collapsed shows fewer rows than the full body. + const collapsedCount = bodyRows(container).length + expect(collapsedCount).toBeLessThan(DEFAULT_DIFF_MAX_LINES + 1) + fireEvent.click(toggle) + expect(screen.getByRole('button', { name: '收起差异' }).getAttribute('aria-expanded')).toBe('true') + expect(bodyRows(container).length).toBeGreaterThan(collapsedCount) + }) + + it('shows no expand control at or under the cap', () => { + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(4) }] + render() + expect(screen.queryByRole('button', { name: /展开其余|收起差异/ })).toBeNull() + }) +}) + +describe('DiffBlock copy', () => { + it('copies the prefixed diff text and flips the label on success', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: 'old', newText: 'new' }, + { path: 'a.ts', oldText: 'p', newText: 'q' }, + ] + render() + const copy = screen.getByRole('button', { name: '复制' }) + await act(async () => { fireEvent.click(copy) }) + // Path header, del/add prefixes, and the same-file gap all reach the clipboard. + expect(writeText).toHaveBeenCalledWith('a.ts\n- old\n+ new\n⋯\n- p\n+ q') + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('keeps the label on a refused clipboard write', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + render() + const copy = screen.getByRole('button', { name: '复制' }) + await act(async () => { fireEvent.click(copy) }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('ignores a second click while the copied label is showing', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + render() + const copy = screen.getByRole('button', { name: '复制' }) + await act(async () => { fireEvent.click(copy) }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制成功' })) }) + expect(writeText).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 9377fc73b8..b1fd862af4 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.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 -README.md: c392d745021c0fc6a752cf71dd0506a435106c50 -README.zh.md: 83ab81e01eae435a74b50fa363a4de203c483002 +# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md +README.md: 241678567c4dbc7411ab9e76f595f2f696cc02d6 +README.zh.md: da4568d109c20bf1860fb8841942d42078b9443a diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index c392d74502..241678567c 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), and the `settings` dictionaries. Feature-owned rows (Language, Appearance) and sections (Models) stay with their feature packages. +Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages. ## Model Experience @@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing. When they gain real backing, each moves to its owning feature plugin per the self-registration doctrine. +- The General section has no built-in rows; each row appears only when its owning feature plugin is mounted. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 83ab81e01e..da4568d109 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置界面文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明),以及 `settings` 字典。归具体功能所有的行(「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 +设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 ## 模型体验 @@ -14,4 +14,4 @@ ## 已知限制与暂缓事项 -- **「权限」与「工具调用」只是展示骨架**:对应的宿主服务和 RPC 方法尚不存在;这些控件已禁用,不会写入任何内容。一旦获得实际支撑,按照自注册原则,每一项都会移至拥有它的功能插件。 +- 「通用」分区没有内置行;每一行仅在其所属功能插件挂载时出现。 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 798a7710f0..af85a9954f 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", - "description": "Settings ownerless-copy plugin: the General section (skeleton rows + item slot), the shell trigger/header chrome content, and the settings dictionaries", + "description": "Settings ownerless-copy plugin: the General section, shell trigger/header chrome content, and settings dictionaries", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index aced3b2962..efa367bc52 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -1,7 +1,5 @@ -/* General section rows (figma 501:29983 'Options'): stacked groups, 16px - * vertical padding each, hairline separator under all but the last child - * (feature-contributed rows carry their own row chrome and separators; the - * :last-child rule strips the trailing one wherever the column ends). */ +/* Feature-contributed rows own their chrome and separators; the section + * strips the trailing separator wherever the column ends. */ .section { display: flex; @@ -12,112 +10,3 @@ .section > :last-child { border-bottom: none; } - -/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */ -.row { - display: flex; - align-items: center; - gap: 8px; - padding: 16px 0; - border-bottom: 1px solid var(--dsw-alias-border-l2); -} - -/* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */ -.group { - display: flex; - flex-direction: column; - gap: 8px; - padding: 16px 0; - border-bottom: 1px solid var(--dsw-alias-border-l2); -} - -/* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */ -.rowText { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; - padding-right: 48px; -} - -.title { - font-size: 14px; - font-weight: 400; - line-height: 22px; - color: var(--dsw-alias-label-primary); -} - -.desc { - font-size: 12px; - font-weight: 400; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - -/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */ -.selector { - display: inline-flex; - align-items: center; - gap: 12px; - height: 36px; - padding: 0 14px; - border: none; - border-radius: 18px; - background: var(--dsw-alias-bg-module-platform); - font: inherit; - font-size: 14px; - line-height: 22px; - color: var(--dsw-alias-label-primary); - cursor: pointer; -} - -.selector:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.selector:disabled { - cursor: default; -} - -.chevron { - flex: none; -} - -/* Tool Call mode cubes share an 8px gap and wrap to one per row when the - panel is too narrow. */ -.cubeRow { - display: flex; - align-items: stretch; - gap: 8px; - flex-wrap: wrap; -} - -/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the - * 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10, - * vertical = inner pad 8). */ -.modeCube { - box-sizing: border-box; - flex: 1 1 276px; - display: flex; - flex-direction: column; - justify-content: center; - gap: 2px; - padding: 8px 14px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 16px; - background: transparent; - text-align: left; - cursor: pointer; -} - -.modeCube:hover:not(.selected) { - background: var(--dsw-alias-interactive-bg-hover); -} - -/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 - * step has no alias-layer name). */ -.selected { - background: var(--dsw-alias-bg-module-platform); - border-color: var(--dsw-static-neutral-bluish-400); -} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx index 62c3a0b134..1217b36f96 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.tsx +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -1,54 +1,19 @@ -/** - * The General section (figma 501:29983 'Options'): Permission and Tool Call - * skeleton rows, then the feature-contributed preference rows from the - * `settings.general.item` slot (locale → Language, ui-theme → Appearance). - * The section column stacks rows; each row draws its own internals and - * separator. - */ -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +/** The General section: one column rendering feature-owned item contributions. */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import css from './GeneralSection.module.css' -/** Full component props: section owner share + item render share + the standard locale seat. */ +/** Full component props: section owner share plus item render share. */ export type GeneralSectionComponentProps = - PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & PropsLocale<'settings'> + PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> /** * Render the General section content column. * @param props - composed slot props (contract/slots.ts). * @returns the section element tree. */ -export function GeneralSection({ t, renderSlot }: GeneralSectionComponentProps) { +export function GeneralSection({ renderSlot }: GeneralSectionComponentProps) { return (
            - {/* Permission (skeleton): disabled selector pill. */} -
            -
            -
            {t('permission.title')}
            -
            {t('permission.desc')}
            -
            - -
            - - {/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */} -
            -
            {t('toolcall.title')}
            -
            -
            -
            {t('toolcall.schema.title')}
            -
            {t('toolcall.schema.desc')}
            -
            -
            -
            {t('toolcall.code.title')}
            -
            {t('toolcall.code.desc')}
            -
            -
            -
            - - {/* Feature-owned preference rows (Language, Appearance, …). */} {renderSlot('settings.general.item', {})}
            ) diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 2683c20608..0a0d84ed80 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,9 +1,8 @@ /** * Settings ownerless-copy plugin, browser half: registers everything on the * Settings surface that belongs to no single feature — the trigger/header - * chrome content, the General section (skeleton rows + the - * `settings.general.item` slot declaration), and the `settings` - * dictionaries. Feature-owned rows and sections stay with their features. + * chrome content, the General section, and the `settings` dictionaries. + * Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' @@ -19,7 +18,9 @@ import { en, zh, type SettingsKey } from './locales.ts' export type { CloseLabelProps, HeaderContentProps, TriggerContentProps, } from './chrome.tsx' -export type { GeneralSectionComponentProps } from './GeneralSection.tsx' +export type { + GeneralSectionComponentProps, +} from './GeneralSection.tsx' export type { SettingsKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index d49dfecf96..b71fc683b9 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -1,28 +1,11 @@ -/** - * `settings` namespace dictionaries: shell chrome plus the shell-owned - * General section (nav label, skeleton rows). Skeleton-row technical copy - * (Read only / Schema mode / Code mode and their descriptions) is shared - * verbatim across locales per the Figma design. Feature-owned rows - * (Language, Appearance) ship their copy in their own packages. - */ -const SHARED = { - 'permission.value': 'Read only', - 'toolcall.schema.title': 'Schema mode', - 'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time', - 'toolcall.code.title': 'Code mode', - 'toolcall.code.desc': 'Chain multiple tools with code — multi-step orchestration', -} satisfies Record +/** Shell chrome and General-nav dictionaries; feature rows own their copy. */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - ...SHARED, 'trigger': '设置', 'title': '设置', 'close': '关闭', 'general.nav': '通用设置', - 'permission.title': '权限', - 'permission.desc': '选择默认权限模式', - 'toolcall.title': '工具调用', } satisfies Record /** The settings namespace key union. */ @@ -30,12 +13,8 @@ export type SettingsKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - ...SHARED, 'trigger': 'Settings', 'title': 'Settings', 'close': 'Close', 'general.nav': 'General', - 'permission.title': 'Permission', - 'permission.desc': 'Choose default permission mode', - 'toolcall.title': 'Tool Call', } satisfies Record diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 726efe0be8..506a69699a 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,6 +1,6 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' @@ -61,11 +61,11 @@ describe('ui-settings-general apply', () => { // The nav label is a locale-following thunk; owners resolve at read time. expect(resolveSlotLabel(entry.options.label)).toBe('通用设置') expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + expect(before.slots.entries('settings.general.item')).toEqual([]) // Copy rides the standard locale seat: every seat declares the namespace. for (const [name] of SEATS) { expect(before.slots.entries(name)[0]!.locale).toBe('settings') } - const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0) @@ -76,6 +76,9 @@ describe('ui-settings-general apply', () => { // The self-inflicted ledger notifications hit the duplicate guard. expect(after.slots.entries(name)).toHaveLength(1) } + await vi.waitFor(() => { + expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + }) }) it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => { @@ -124,6 +127,7 @@ describe('ui-settings-general apply', () => { for (const [name, component] of SEATS) { expect(b.slots.entries(name)[0]!.component).toBe(component) } + expect(b.slots.entries('settings.general.item')).toEqual([]) expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) // The recovered registrations still ride the locale path. b.locale.setLocale('en') diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 9af2fb825b..db6be78ccd 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -4,13 +4,14 @@ import { cleanup, render, screen } from '@testing-library/react' import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' +import type { TriggerContentProps } from '../src/client/chrome.tsx' import { en } from '../src/client/locales.ts' afterEach(cleanup) // The seat's key domain is settings ∪ common; the stub answers from the // package dictionary and falls back to the key like the real chain. -const t: GeneralSectionComponentProps['t'] = key => (en as Record)[key] ?? key +const t: TriggerContentProps['t'] = key => (en as Record)[key] ?? key // Global standard kit stubs: none of these components consume the hooks. const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never @@ -42,31 +43,12 @@ describe('GeneralSection', () => { const renderSlot = vi.fn( ((key: string) =>
            ) as GeneralSectionComponentProps['renderSlot'], ) - const props: GeneralSectionComponentProps = { ...kit, t, renderSlot } + const props: GeneralSectionComponentProps = { ...kit, renderSlot } const view = render() return { view, renderSlot } } - it('renders the Permission skeleton row with the disabled selector', () => { - mount() - expect(screen.getByText('Permission')).toBeTruthy() - expect(screen.getByText('Choose default permission mode')).toBeTruthy() - const selector = screen.getByRole('button', { name: /Read only/ }) - expect(selector.disabled).toBe(true) - }) - - it('renders the Tool Call skeleton cubes with schema pinned selected', () => { - mount() - expect(screen.getByText('Tool Call')).toBeTruthy() - const schema = screen.getByText('Schema mode') - const code = screen.getByText('Code mode') - expect(schema.parentElement!.className).toContain('selected') - expect(code.parentElement!.className).not.toContain('selected') - expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy() - expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy() - }) - - it('renders the feature-contributed item slot after the skeleton rows', () => { + it('renders the item slot as the section body', () => { const { renderSlot } = mount() expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {}) expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx index f21fb26bdd..d028affd69 100644 --- a/packages/client/ui-theme/tests/appearance-row.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -27,7 +27,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 5c1e6c691d..1ebe8d2f61 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -113,7 +113,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 3c61535e06..a21fd21697 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: f71bfa09c795bd69e1f49c8f6dffffd5959dbe47 -README.zh.md: 80b53d85eb210b0e7a7ace1699d6bbfc9a836606 +README.md: cc73214a281c6950acf8846f0bae3214c8726934 +README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index f71bfa09c7..cc73214a28 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. @@ -23,5 +23,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. -- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions. +- **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 80b53d85eb..c0b6472c7d 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,7 +6,7 @@ 该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 @@ -23,5 +23,5 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork, ## 已知限制与暂缓事项 - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 -- **没有 Session 删除控件**:Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 +- **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index f5b636ab1b..97cc2116a4 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -102,18 +102,22 @@ type SessionTreeProps = Pick< 'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't' > & { workspaces: readonly WorkspaceView[] + /** Registry-global archive set (hidden rows). */ + archivedSessionIds: readonly SessionNode['id'][] /** Open the browser-owned rename dialog for a real Workspace group. */ onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */ onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void /** Open the browser-owned session rename dialog. */ onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void + /** Archive a session (row menu action; the row disappears on the state echo). */ + onSessionArchive: (sessionId: SessionNode['id']) => void } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ function SessionTree({ - useSessions, startSession, open, forkSession, workspaces, - onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t, + useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, + onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current @@ -129,8 +133,8 @@ function SessionTree({ setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup])) }, [current, currentGroup]) const groups = useMemo( - () => deriveGroups(list, workspaces, { expandedProjects }), - [list, workspaces, expandedProjects], + () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }), + [list, workspaces, archivedSessionIds, expandedProjects], ) const now = Date.now() @@ -209,6 +213,7 @@ function SessionTree({ onOpen={open} onRename={onSessionRename} onFork={forkSession} + onArchive={onSessionArchive} drag={dragProps} t={t} /> @@ -223,9 +228,11 @@ function SessionTree({ } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick) { +function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick< + SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't' +>) { const list = useSessions(s => s) - const rows = useMemo(() => deriveFlat(list), [list]) + const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds]) const now = Date.now() return (
            @@ -242,6 +249,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick ))} @@ -263,12 +271,14 @@ function SearchResults({ useSessions, open, workspaces, + archivedSessionIds, query, remote, resultLimit, t, }: Pick & { workspaces: readonly WorkspaceView[] + archivedSessionIds: readonly SessionNode['id'][] query: string remote: RemoteSearchState resultLimit: number @@ -278,8 +288,8 @@ function SearchResults({ ? remote : { query, status: 'loading' as const, items: [], hasMore: false } const results = useMemo( - () => deriveSearchResults(list, workspaces, query, currentRemote, resultLimit), - [list, workspaces, query, currentRemote, resultLimit], + () => deriveSearchResults(list, workspaces, query, archivedSessionIds, currentRemote, resultLimit), + [list, workspaces, query, archivedSessionIds, currentRemote, resultLimit], ) const pending = currentRemote.status === 'loading' const failed = currentRemote.status === 'error' @@ -337,6 +347,7 @@ export function WorkspaceBrowser({ forkSession, renameWorkspace, deleteWorkspace, + archiveSession, insertSessionBefore, createWorkspace, searchSessions, @@ -346,6 +357,7 @@ export function WorkspaceBrowser({ t, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) + const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) const groupBy = useStore(s => s.groupBy) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. @@ -475,6 +487,16 @@ export function WorkspaceBrowser({ setSessionRenameError(null) } + // Archive is dialog-free: not destructive (the log and the accounting slot + // remain), so the menu action commits directly; the row disappears when the + // archive-set echo lands. Failures are non-fatal console diagnostics, the + // same posture as reorder rejections. + const onSessionArchive = (sessionId: SessionNode['id']) => { + archiveSession(sessionId).catch((reason: unknown) => { + console.warn('session archive rejected:', reason) + }) + } + // Delete dialog is separate from the row so a successful removal can // unmount that row without tearing down the in-flight confirmation state. const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null) @@ -597,6 +619,7 @@ export function WorkspaceBrowser({ useSessions={useSessions} open={open} workspaces={workspaces} + archivedSessionIds={archivedSessionIds} query={normalizedQuery} remote={remoteSearch} resultLimit={searchResultLimit} @@ -607,15 +630,18 @@ export function WorkspaceBrowser({ ? ( ) : ( Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ deleteWorkspace: (workspaceId: WorkspaceId) => Promise + /** + * Archive a Session into the registry-global set: hidden from grouping + * surfaces, log and accounting slot retained. Archiving the current + * session clears the selection into the New Session view state. + */ + archiveSession: (sessionId: SessionId) => Promise /** * Reorder a session inside its Workspace account (DOM-insertBefore * semantics: omitted anchor appends to the end). The view refreshes from diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 7dd8db5419..59574beed3 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -92,6 +92,7 @@ export function apply(ctx: ClientContext): void { }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, + archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 3d8a5d657f..1ecc244329 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -45,7 +45,7 @@ export const zh = { 'delete.desc': '将把“{name}”从工作区列表中移除。文件夹与会话记录会保留,其会话将显示在“未分组”下。', 'delete.pending': '正在删除工作区…', 'menu.fork': '分叉会话', - 'menu.deleteSession': '删除会话', + 'menu.archiveSession': '归档会话', 'sessions.count.one': '{n} 个会话', 'sessions.count.other': '{n} 个会话', 'actions.workspace.aria': '工作区“{name}”的操作', @@ -108,7 +108,7 @@ export const en = { 'delete.desc': 'This removes “{name}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.', 'delete.pending': 'Deleting workspace…', 'menu.fork': 'Fork session', - 'menu.deleteSession': 'Delete session', + 'menu.archiveSession': 'Archive session', 'sessions.count.one': '{n} session', 'sessions.count.other': '{n} sessions', 'actions.workspace.aria': 'Workspace actions for {name}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 03634dbe1a..77583140c1 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -2,14 +2,14 @@ * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — * all data and callbacks arrive via props. Hover swaps (folder->chevron, * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only - * except workspace Rename/Delete and session Rename/Fork; the session and - * workspace hover cards are suppressed while a menu is open. + * except workspace Rename/Delete and session Rename/Fork/Archive; the session + * and workspace hover cards are suppressed while a menu is open. */ import { useState } from 'react' import clsx from 'clsx' import { - HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16, - IconFolderClose16, IconFolderOpen16, IconPlusOutline16, + HoverCard, IconBranchOutline16, IconDownloadOutline16, IconEditOutline16, + IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceBrowserProps } from '../contract/slots.ts' @@ -175,7 +175,9 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; return (
            {displayTitle(node, t)}
            -
            {hoverTimeLabel(node.updatedAt, now, t)}
            + {/* Same placeholder rule as the row's trailing cell: no timestamp + before the first prompt. */} + {!node.blank &&
            {hoverTimeLabel(node.updatedAt, now, t)}
            }
            {node.running ? t('status.running') : t('status.idle')} @@ -243,7 +245,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } -export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag, t }: { +export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: { node: SessionNode currentId: string | undefined now: number @@ -252,6 +254,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork onRename: (id: SessionNode['id'], currentTitle: string) => void /** Fork a session at its last completed turn (row menu action). */ onFork: (id: SessionNode['id']) => void + /** Archive this session (row menu action; commits without a dialog). */ + onArchive: (id: SessionNode['id']) => void /** Present only on draggable rows (workspace-group sessions outside search). */ drag?: RowDragProps | undefined t: RowTranslate @@ -260,10 +264,13 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const title = displayTitle(node, t) const selected = node.id === currentId const [menuOpen, setMenuOpen] = useState(false) + // Archive replaces the former Delete placeholder: it hides the row through + // the registry-global archive set and never touches the session log, so it + // is not styled as destructive and needs no confirmation dialog. const sessionMenuItems = [ { id: 'rename', label: t('rename'), icon: }, { id: 'fork', label: t('menu.fork'), icon: }, - { id: 'delete', label: t('menu.deleteSession'), icon: , danger: true }, + { id: 'archive', label: t('menu.archiveSession'), icon: }, ] // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = ( @@ -301,31 +308,38 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork > {row.running && } {title} - {timeLabel(row.updatedAt, now, t)} - - { setMenuOpen(false) }} - items={sessionMenuItems} - onSelect={(id) => { - setMenuOpen(false) - if (id === 'rename') onRename(node.id, row.title) - if (id === 'fork') onFork(node.id) // delete stays visual-only. - }} - portal - closeOnPointerLeave - anchor={( - - )} - /> - + {/* A blank New Session row is a provisional placeholder: nothing has + happened in it yet, so a "now" timestamp and the row verbs + (rename/fork/archive) would all act on content that does not + exist — both trailing cells stay off until the first prompt. */} + {!row.blank && {timeLabel(row.updatedAt, now, t)}} + {!row.blank && ( + + { setMenuOpen(false) }} + items={sessionMenuItems} + onSelect={(id) => { + setMenuOpen(false) + if (id === 'rename') onRename(node.id, row.title) + if (id === 'fork') onFork(node.id) + if (id === 'archive') onArchive(node.id) + }} + portal + closeOnPointerLeave + anchor={( + + )} + /> + + )}
            ) return ( diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index aed01c21cb..7ed7e684d1 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -90,9 +90,13 @@ function byRecency(a: SessionSummary, b: SessionSummary): number { return a.id < b.id ? -1 : 1 } -/** Ordinary sessions are visible; among blank sessions, only the current one is visible. */ -function sessionVisible(session: SessionSummary, current: SessionId | undefined): boolean { - return !session.blank || session.id === current +/** + * Ordinary sessions are visible; among blank sessions, only the current one + * is visible; archived sessions are visible nowhere (their accounting slots + * remain, so unarchiving restores position). + */ +function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet): boolean { + return !archived.has(session.id) && (!session.blank || session.id === current) } /** @@ -126,7 +130,11 @@ function buildGroup( * order, with members resolved from sessionIds in their stored order. Sessions * outside every Workspace trail in the recency-ordered Ungrouped bucket. */ -function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] { +function groupByWorkspace( + list: SessionListState, + workspaces: readonly WorkspaceView[], + archived: ReadonlySet, +): Group[] { const groups: Group[] = [] const accounted = new Set() for (const workspace of workspaces) { @@ -135,7 +143,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace const summary = list.byId[id] if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands accounted.add(id) - if (!sessionVisible(summary, list.current)) continue + if (!sessionVisible(summary, list.current, archived)) continue members.push(summary) } groups.push(buildGroup( @@ -146,7 +154,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace const stray = list.ids .map(id => list.byId[id]) .filter((s): s is SessionSummary => - s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current)) + s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived)) if (stray.length > 0) { groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency')) } @@ -168,25 +176,29 @@ function sessionNode(s: SessionSummary): SessionNode { * * Every group shows; sessions populate under expanded groups, preserving * Host account order. Blank sessions are excluded except for the selected - * provisional New Session row. Content search lives outside this derivation + * provisional New Session row; archived sessions are excluded everywhere. + * Content search lives outside this derivation * (see {@link deriveSearchResults}). * @param list - sessions list snapshot (`current` feeds containsCurrent). * @param workspaces - real workspaces in stable Host order. + * @param archivedSessionIds - registry-global archive set. * @param view - local expansion arrays. * @returns group sections in render order. */ export function deriveGroups( list: SessionListState, workspaces: readonly WorkspaceView[], + archivedSessionIds: readonly SessionId[], view: TreeView, ): GroupNode[] { + const archived = new Set(archivedSessionIds) const expandedProjects = new Set(view.expandedProjects) const currentGroup = list.current === undefined ? undefined : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) ?? UNGROUPED_KEY const groups: GroupNode[] = [] - for (const g of groupByWorkspace(list, workspaces)) { + for (const g of groupByWorkspace(list, workspaces, archived)) { const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, @@ -209,13 +221,15 @@ export function deriveGroups( * no parent/child adjacency. Content search lives outside this derivation * (see {@link deriveSearchResults}). * @param list - sessions list snapshot. + * @param archivedSessionIds - registry-global archive set. * @returns flat rows in render order. */ -export function deriveFlat(list: SessionListState): SessionNode[] { +export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] { + const archived = new Set(archivedSessionIds) const rows: SessionSummary[] = [] for (const id of list.ids) { const s = list.byId[id] - if (s === undefined || !sessionVisible(s, list.current)) continue + if (s === undefined || !sessionVisible(s, list.current, archived)) continue rows.push(s) } rows.sort(byRecency) @@ -238,6 +252,7 @@ export interface RelativeTime { * @param list - session metadata authority. * @param workspaces - Workspace membership and display labels. * @param query - caller text; surrounding whitespace is ignored. + * @param archivedSessionIds - registry-global archive set (members never match). * @param content - ranked Host content-search page. * @param limit - protocol-owned maximum merged row count. * @returns bounded deduplicated flat rows and a refine-query hint bit. @@ -246,11 +261,13 @@ export function deriveSearchResults( list: SessionListState, workspaces: readonly WorkspaceView[], query: string, + archivedSessionIds: readonly SessionId[], content: { items: readonly SessionSearchResultItem[]; hasMore: boolean }, limit: number, ): SearchResultSet { const q = query.trim().toLowerCase() if (q === '') return { items: [], hasMore: false } + const archived = new Set(archivedSessionIds) const workspaceBySession = new Map() for (const workspace of workspaces) { @@ -270,7 +287,7 @@ export function deriveSearchResults( const summary = list.byId[id] // Blank placeholders never match a query (their canonical title displays // localized, so matching it would tie search to one language). - if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue + if (summary === undefined || summary.blank || !sessionVisible(summary, list.current, archived)) continue if ( sessionTitle(summary).toLowerCase().includes(q) || labelOf(summary).toLowerCase().includes(q) @@ -290,7 +307,7 @@ export function deriveSearchResults( for (const summary of local) include(summary) for (const item of content.items) { const summary = list.byId[item.sessionId] - if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary) + if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current, archived)) include(summary) } return { diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 59fe95fe0c..c0b4959b17 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -88,7 +88,7 @@ describe('workspace browser rows', () => { const onOpen = vi.fn() render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />, ) const row = screen.getByRole('treeitem') @@ -157,18 +157,43 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull() }) - it('session row menu opens without opening the session and dispatches rename and fork', () => { + it('blank New Session rows carry no menu, no time label, and no hover-card time', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0, + } + render() + // The placeholder has no content yet: no row verbs, no "now" stamp. + expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull() + expect(screen.queryByText('刚刚')).toBeNull() + // The hover card keeps title + status but drops the timestamp line. + const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2) + expect(screen.getByText('空闲')).toBeTruthy() + expect(screen.queryByText('刚刚')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => { const onOpen = vi.fn() const onRename = vi.fn() const onFork = vi.fn() + const onArchive = vi.fn() const node: SessionNode = { id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0, } render() + onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />) fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) expect(onOpen).not.toHaveBeenCalled() - expect(screen.getByRole('menuitem', { name: '删除会话' }).className).toMatch(/danger/) + // Archive is not destructive (log and accounting slot remain): no danger styling. + expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/) // Rename dispatches with the current display title (dialog prefill). fireEvent.click(screen.getByRole('menuitem', { name: '重命名' })) expect(screen.queryByRole('menu')).toBeNull() @@ -177,10 +202,12 @@ describe('workspace browser rows', () => { fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' })) expect(onFork).toHaveBeenCalledWith(node.id) - // Delete stays visual-only. + // Archive dispatches without opening the session. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) - fireEvent.click(screen.getByRole('menuitem', { name: '删除会话' })) + fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) + expect(onArchive).toHaveBeenCalledWith(node.id) expect(onRename).toHaveBeenCalledOnce() + expect(onOpen).not.toHaveBeenCalled() // Escape closes without selecting (Menu onClose path). fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) fireEvent.keyDown(document, { key: 'Escape' }) @@ -194,7 +221,7 @@ describe('workspace browser rows', () => { id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) @@ -220,7 +247,7 @@ describe('workspace browser rows', () => { id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('空闲')).toBeTruthy() @@ -237,7 +264,7 @@ describe('workspace browser rows', () => { const inactive = dragProps() const { rerender } = render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />, ) const row = screen.getByRole('treeitem') stubRect(row) @@ -255,7 +282,7 @@ describe('workspace browser rows', () => { const active = dragProps({ active: true, marker: 'before' }) rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={active} t={t} />, ) stubRect(screen.getByRole('treeitem')) // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). @@ -269,7 +296,7 @@ describe('workspace browser rows', () => { const after = dragProps({ active: true, marker: 'after' }) rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />, ) expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) }) diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 8fb750a4ad..7476de29f5 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -26,19 +26,21 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView const view = (expandedProjects: readonly string[] = []) => ({ expandedProjects, }) +const noArchive: readonly SessionId[] = [] +const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid) describe('deriveGroups', () => { it('keeps Host Workspace and sessionIds order without Client recency sorting', () => { const sessions = list(summary('newer', 20), summary('older', 10)) const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])] - const groups = deriveGroups(sessions, workspaces, view(['first'])) + const groups = deriveGroups(sessions, workspaces, noArchive, view(['first'])) expect(groups.map(group => group.key)).toEqual(['first', 'empty']) expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) - const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY])) + const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY])) expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY]) expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')]) }) @@ -52,7 +54,7 @@ describe('deriveGroups', () => { current: currentBlank.id, } const groups = deriveGroups( - sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], view(['first']), + sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], noArchive, view(['first']), ) expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id]) const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)! @@ -63,7 +65,7 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false) expect(groups[0]!.sessionCount).toBe(2) // A non-current blank stray never surfaces an Ungrouped bucket either. - const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], view()) + const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], noArchive, view()) expect(strayGroups.map(group => group.key)).toEqual(['first']) }) @@ -80,6 +82,7 @@ describe('deriveGroups', () => { const groups = deriveGroups( list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB), [], + noArchive, { expandedProjects: [UNGROUPED_KEY] }, ) @@ -90,7 +93,7 @@ describe('deriveGroups', () => { ]) // Equal timestamps use ids as a deterministic tiebreak in either input order. - expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]! + expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, view([UNGROUPED_KEY]))[0]! .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')]) }) @@ -100,17 +103,32 @@ describe('deriveGroups', () => { ids: [sid('present')], byId: { [sid('present')]: summary('present', 1) }, } - const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project'])) + const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], noArchive, view(['project'])) expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')]) }) + it('hides archived sessions from workspace groups and Ungrouped', () => { + const kept = summary('kept', 1, '/projects/first') + const gone = summary('gone', 2, '/projects/first') + const looseGone = summary('loose-gone', 3, '/other') + const sessions = list(kept, gone, looseGone) + const groups = deriveGroups( + sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), view(['first', UNGROUPED_KEY]), + ) + // The archived member drops from its group AND the archived stray never + // surfaces an Ungrouped bucket; counts follow the visible rows. + expect(groups.map(group => group.key)).toEqual(['first']) + expect(groups[0]!.sessions.map(node => node.id)).toEqual([kept.id]) + expect(groups[0]!.sessionCount).toBe(1) + }) + it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => { const owned = summary('owned', 1) const loose = summary('loose', 2) const ws = workspace('project', ['owned']) - const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view()) + const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], noArchive, view()) expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true) - const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view()) + const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], noArchive, view()) expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true) }) }) @@ -121,13 +139,13 @@ describe('deriveFlat', () => { const child = { ...summary('child', 30), parentId: parent.id } const tieB = summary('tie-b', 20) const tieA = summary('tie-a', 20) - const rows = deriveFlat(list(parent, child, tieB, tieA)) + const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive) expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')]) }) it('tolerates ids whose summary has not landed yet', () => { const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } - expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')]) + expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')]) }) it('shows only the current blank session and excludes blanks from search', () => { @@ -137,11 +155,35 @@ describe('deriveFlat', () => { ...list(summary('real', 1), currentBlank, staleBlank), current: currentBlank.id, } - const rows = deriveFlat(sessions) + const rows = deriveFlat(sessions, noArchive) expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')]) expect(rows.map(row => row.title)).toEqual(['New Session', 'real']) expect(rows.map(row => row.blank)).toEqual([true, false]) }) + + it('hides archived sessions in flat mode', () => { + const kept = summary('kept', 1) + const gone = summary('gone', 2) + expect(deriveFlat(list(kept, gone), archived('gone')).map(row => row.id)).toEqual([kept.id]) + }) +}) + +describe('deriveSearchResults archive filtering', () => { + it('archived sessions never match — not by title and not via a backend content hit', () => { + const hit = summary('hit', 2) + hit.displayTitle = 'Needle row' + const gone = summary('gone', 1) + gone.displayTitle = 'Needle archived' + const result = deriveSearchResults( + list(hit, gone), + [], + 'needle', + archived('gone'), + { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false }, + 10, + ) + expect(result.items.map(item => item.id)).toEqual([hit.id]) + }) }) describe('deriveSearchResults', () => { @@ -160,6 +202,7 @@ describe('deriveSearchResults', () => { workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'), ], ' NEEDLE ', + noArchive, { items: [ { sessionId: contentHit.id, snippet: 'body needle excerpt' }, @@ -212,6 +255,7 @@ describe('deriveSearchResults', () => { sessions, [workspace('first', ['opaque-current', 'new session stale'])], 'new session', + noArchive, { items: [ { sessionId: staleBlank.id, snippet: 'stale body' }, @@ -234,6 +278,7 @@ describe('deriveSearchResults', () => { list(...rows), [], 'needle', + noArchive, { items: [], hasMore: false }, 3, ) @@ -244,12 +289,13 @@ describe('deriveSearchResults', () => { list(summary('body', 1)), [], 'needle', + noArchive, { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true }, 3, ) expect(backendMore.items).toHaveLength(1) expect(backendMore.hasMore).toBe(true) - expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }, 3)) + expect(deriveSearchResults(list(), [], ' ', noArchive, { items: [], hasMore: true }, 3)) .toEqual({ items: [], hasMore: false }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7dc3f7239b..e5c89ce0de 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -35,8 +35,8 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView workspaceId: wid(id), path: `/projects/${id}`, title, sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }) -const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, state: 'idle', phase: 'ready', error: null, baselinesReady: true, +const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[] = []): WorkspaceListState => ({ + items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: items[0]?.workspaceId, }) function hook(snapshot: T) { @@ -68,6 +68,7 @@ function mount(overrides: Partial = {}) { forkSession: vi.fn(), renameWorkspace: vi.fn(async () => {}), deleteWorkspace: vi.fn(async () => {}), + archiveSession: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), @@ -135,6 +136,50 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('alpha-s')).toBeNull() }) + it('archives a session from the row menu and hides archived rows in both modes', async () => { + const archiveSession = vi.fn(async () => {}) + const b = mount({ + useSessions: hook(sessionState([summary('kept-s', 2), summary('gone-s', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])])), + archiveSession, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“gone-s”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) + expect(archiveSession).toHaveBeenCalledWith(sid('gone-s')) + + // The archive-set echo hides the row in grouped mode (count included) and flat mode. + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) }) + expect(screen.queryByText('gone-s')).toBeNull() + expect(screen.getByText('1 个会话')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) + expect(screen.getByText('kept-s')).toBeTruthy() + expect(screen.queryByText('gone-s')).toBeNull() + }) + + it('logs and keeps the tree when the archive call rejects', async () => { + const rejection = new Error('archive exploded') + const archiveSession = vi.fn(async () => { throw rejection }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + archiveSession, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“alpha-s”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) + await Promise.resolve() + await Promise.resolve() + expect(warn).toHaveBeenCalledWith('session archive rejected:', rejection) + expect(screen.getByText('alpha-s')).toBeTruthy() + } finally { + warn.mockRestore() + } + }) + it('renders a fork child as a top-level row without a session twist', () => { const parent = summary('parent-s', 2) const child = { ...summary('child-s', 1), parentId: parent.id } diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 3a1bb8e2b2..b2d1479d17 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -32,7 +32,7 @@ const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, state: 'idle', phase: 'ready', error: null, baselinesReady: true, + items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: items[0]?.workspaceId, }) function anchor(): { current: HTMLElement } { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9c413fa815..b888f33ed4 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1162,6 +1162,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'delete(id: WorkspaceId): Promise', jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */', }, + { + signature: 'archiveSession(sessionId: SessionId): Promise', + jsDoc: '/**\n * Archive one session durably. The session must exist (live or in session\n * persistence); its workspace accounting — or lack of one — is irrelevant.\n * An already archived id resolves without writing.\n * @param sessionId - The session to archive.\n * @returns resolution after durability.\n */', + }, { signature: 'async resolveByPath(path: string): Promise', jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */', @@ -2207,6 +2211,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PtyWaitReason', declaration: 'export type PtyWaitReason = \'stdin_read\' | \'inferred_idle\' | \'timeout\' | \'session_exit\';', }, + { + name: 'ReadFileLine', + declaration: 'export interface ReadFileLine {\n number: number;\n text: string;\n}', + }, + { + name: 'ReadResultView', + declaration: 'export interface ReadResultView {\n card: \'read\';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n}', + }, { name: 'ReasoningBlock', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', @@ -2865,7 +2877,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolResultView', - declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | WebResultView;', + declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;', }, { name: 'ToolRunContext', diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 403fa25b32..88d02b9a6e 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -414,7 +414,9 @@ export class Session { * start here. Distinct from `header.seedLength`, the DURABLE fork-lineage * boundary: a resumed session's constructor seed is its full stored log, * while its header keeps the original fork value — this field is the - * in-process construction fact. + * in-process construction fact. An explicitly supplied empty seed has the + * same value as no seed (0); its `session/end-seed` event preserves the + * lifecycle distinction. * * Not persisted itself: a seeded session projects it into the log as the * `session/end-seed` event, which is what a consumer reading STORED history @@ -430,7 +432,7 @@ export class Session { readonly firstLiveSeq: number constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { - if (seed) { + if (seed !== undefined) { // Validate the seed to the SAME invariants `append` enforces, so a // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a // live log that no persistence backend could store: each event's `data` @@ -467,7 +469,7 @@ export class Session { // captures the creation seed: no load-time write. Re-marking is skipped // because a cold session is resumed on first touch, so repeatedly opening // one must not grow its log per open. - if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/end-seed') { + if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') { this.append('session/end-seed', {}) } } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 1f7a8583d6..cbfc2d5a41 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -256,7 +256,9 @@ export interface SessionEventMap { /** * Marks the end of a constructor seed. Events before it have smaller seq * values and came from the seed (resume, fork, or replay); this lifecycle - * produced none of them. This log-only event is the durable projection of + * produced none of them. An explicitly supplied empty seed puts the marker + * at seq 0, distinguishing an empty resumed session from a fresh session. + * This log-only event is the durable projection of * {@link Session.firstLiveSeq}. Its payload is empty — position and `time` * carry the meaning. * diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index b04d08e9ab..b0381f3c3f 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -67,7 +67,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, undefined, SessionId('empty-child')) - expect(child.events).toEqual([]) + expect(inherited(child)).toEqual([]) expect(child.header).toMatchObject({ id: SessionId('empty-child'), cwd: '/workspace', diff --git a/packages/core/session/tests/properties.spec.ts b/packages/core/session/tests/properties.spec.ts index 398bd69d35..c57eb247cb 100644 --- a/packages/core/session/tests/properties.spec.ts +++ b/packages/core/session/tests/properties.spec.ts @@ -112,9 +112,9 @@ describe('Session properties', () => { const original = build(events) const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events]) expect(replayed.deriveMessages()).toEqual(original.deriveMessages()) - // A non-empty replay grows by exactly one log-only boundary. + // Every explicit replay grows by exactly one log-only boundary. expect(replayed.events.slice(0, original.seq)).toEqual(original.events) - expect(replayed.seq).toBe(original.seq === 0 ? 0 : original.seq + 1) + expect(replayed.seq).toBe(original.seq + 1) })) }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 0d8663ea6c..f86b8b59f5 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -194,6 +194,21 @@ describe('Session', () => { expect(replayed.firstLiveSeq).toBe(original.seq) }) + it('marks an explicitly empty seed without marking a fresh session', () => { + const fresh = new Session(SessionId('fresh-empty')) + expect(fresh.events).toEqual([]) + + const resumed = new Session(SessionId('resumed-empty'), []) + expect(resumed.firstLiveSeq).toBe(0) + expect(resumed.events).toMatchObject([ + { type: 'session/end-seed', seq: 0, data: {} }, + ]) + + const reopened = new Session(SessionId('reopened-empty'), resumed.events) + expect(reopened.firstLiveSeq).toBe(1) + expect(reopened.events).toEqual(resumed.events) + }) + it('rejects pre-provider request headers and assistant messages on seed/load', () => { const requestHeader = { type: 'request/header', seq: 0, time: 1, diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index b2b8184b97..d26f891f91 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: b1be8c012287a686afd3524b0bcefa419839c6a2 -README.zh.md: 6c757f9b7b727700a9ee635a4b3a4c848ce45150 +README.md: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda +README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index b1be8c0122..15fc5839a3 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names: - Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. -- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search — grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob), with `truncated`/`total` so a UI never presents a capped result as complete; the view carries no result text and a search has no `card: 'search'` call-time analogue), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content). +- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search — grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob), with `truncated`/`total` so a UI never presents a capped result as complete; the view carries no result text and a search has no `card: 'search'` call-time analogue), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content). Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 6c757f9b7b..8547ee4a79 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -108,7 +108,7 @@ ctx.tools.register(defineTool({ 工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称: - 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。 -- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索——`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表,配 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现;该视图不携带结果文本,且搜索没有 `card: 'search'` 的调用时对应视图)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。 +- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索——`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表,配 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现;该视图不携带结果文本,且搜索没有 `card: 'search'` 的调用时对应视图)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。 返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index d386f361fd..f30dce6cd0 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -74,6 +74,7 @@ export type { ToolCallKind, FileLocation, FileDiff, + ReadFileLine, ToolCallView, GenericCallView, TerminalCallView, @@ -87,6 +88,7 @@ export type { SearchPathsResultView, SearchFileMatches, SearchLineMatch, + ReadResultView, WebResultView, WebSearchResultView, WebFetchResultView, diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index 62a4ca510f..5ca7bc5989 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -117,6 +117,18 @@ export interface DiffCallView { locations?: FileLocation[] } +/** + * One numbered line of a file, the unit a {@link ReadResultView} carries so a + * capable UI can render a syntax-highlighted, line-numbered code view. `number` + * is the 1-based line number in the file (a window past `offset` keeps the file's + * own numbering, not a 1-based re-count); `text` is the line without its trailing + * newline, already truncated to the read tool's per-line cap. + */ +export interface ReadFileLine { + number: number + text: string +} + /** * How a tool wants the COMPLETED call shown — the *result* state, after `execute` * returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on @@ -125,7 +137,7 @@ export interface DiffCallView { * `ToolDefinition.presentResult`; omitting the method keeps the pending * title and renders the raw result content. */ -export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | WebResultView +export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView /** * The default completed card: an optional replacement title and reformatted @@ -254,6 +266,47 @@ export interface SearchPathsResultView { */ export type SearchResultView = SearchMatchesResultView | SearchPathsResultView +/** + * A completed file read rendered as a line-numbered, optionally syntax-highlighted + * code view by a capable UI. Set by a tool whose call reads file text (e.g. + * `read`); the pending state stays a {@link GenericCallView} (`kind: 'read'`) + * because a call carries no content until `execute` returns. The structured + * `lines`/`path`/`lang`/`totalLines` fields cannot be reconstructed from the + * model-facing result text alone, so the read tool projects them through its + * `output.presentationMeta` (persisted with the session log) and `presentResult` + * narrows that metadata back into this view on live and replay paths alike. A UI + * without the read capability falls back to `content` (the model-facing text with + * its envelope stripped), so this view degrades to the generic text card. + */ +export interface ReadResultView { + card: 'read' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** The read file's path (the model-facing path; the bridge relativizes it). */ + path: string + /** + * The 1-based first line the window requested, preserved even when `lines` is + * empty (a byte cap below the first selected line yields an empty window) so a + * UI knows where the window starts and where a continuation resumes. + */ + offset: number + /** The returned window's lines, in file order, each keeping its file line number. */ + lines: ReadFileLine[] + /** Exact total line count in the file, so a UI can show a "showing N of M" affordance. */ + totalLines: number + /** + * A syntax-highlighting language hint derived from the file extension (e.g. + * `ts`, `py`), or omitted when the extension maps to no known language so a UI + * renders the lines as plain text. + */ + lang?: string + /** + * The model-facing result content with its envelope stripped, for a UI without + * the read capability. Omit to let such a UI render the raw result content. + */ + content?: ContentBlock[] +} + /** * One citeable source in a completed {@link WebSearchResultView}, the faithful * projection of one web-search source. The presentation projection of `dsh-web`'s diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index a3ebe97bc4..65c6b65268 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69 -README.zh.md: ce93e10072d74ce268273aa472bfbb3f34f46259 +README.md: c00b59fed06249e6d9479c4a809cdf7d78f93239 +README.zh.md: f90fbb36391c1388ab0f6836daa2a9061d046be6 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 4ff9b04352..c00b59fed0 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -34,7 +34,7 @@ All keys are optional; the defaults are the shipped read caps. Field names are snake_case to match Claude Code and existing harness tool schemas. -Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`. +Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted. ## The tool is the executor; policy is an event gate diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index ce93e10072..f90fbb3639 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -34,7 +34,7 @@ await ctx.plugin(ToolFs) // this package — re 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;这些值本身仅限于本次执行,不会添加到 `tool/result`。 +规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。 ## 工具就是执行器;策略是事件门禁 diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 7e581bb22c..19b6c0b1e7 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -168,3 +168,105 @@ export function formatReadOutput(displayPath: string, outcome: FileReadOutcome): ${body} ` } + +/** + * Lowercased file-extension to syntax-highlighting language hint. Keys are the + * extension without its dot; a UI treats an absent key as plain text. The map is + * intentionally small — common source, config, and markup extensions a + * line-numbered code view benefits from highlighting — not an exhaustive registry. + */ +const LANG_BY_EXTENSION: Readonly> = { + ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', + js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', + json: 'json', jsonc: 'json', + py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java', + c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp', + cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php', + sh: 'sh', bash: 'sh', zsh: 'sh', + yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', + md: 'md', markdown: 'md', mdx: 'mdx', + html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less', + sql: 'sql', xml: 'xml', lua: 'lua', +} + +/** + * Derive a syntax-highlighting language hint from a read path's file extension. + * Pure and case-insensitive on the extension; a dotfile with no extension + * (`.gitignore`) and an unknown extension both yield `undefined`. + * @param path - the model-facing path the read reported. + * @returns the language hint for {@link LANG_BY_EXTENSION}, or `undefined` when the extension maps to none. + */ +export function langFromPath(path: string): string | undefined { + const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) + const dot = base.lastIndexOf('.') + // A leading dot is a dotfile (no extension), not an empty extension. + if (dot <= 0) return undefined + const ext = base.slice(dot + 1).toLowerCase() + // Own-property check only: a filename whose extension is an Object.prototype + // key (`foo.constructor`, `foo.__proto__`) must map to no language, not to the + // inherited member — otherwise a function would reach `lang` and fail the + // tool-output JSON validation. + return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined +} + +/** + * The `read` tool's private `tool/result` `meta` payload: the structured + * line-numbered window a capable UI renders as a code view. Attached opaquely (as + * `unknown`) on the tool result and persisted with the session log — it must be + * JSON-serializable (the session validates this at `append`), so `presentResult` + * reproduces the read card on replay when the raw structured output is no longer + * on the wire. The producing tool owns and narrows this opaque shape. + */ +export interface FsReadMeta { + /** The read file's model-facing path. */ + path: string + /** The 1-based first line the window requested, kept even when `lines` is empty. */ + offset: number + /** The returned window's lines, each keeping its file line number. */ + lines: FileTextLine[] + /** Exact total line count in the file. */ + totalLines: number + /** Syntax-highlighting language hint from the extension, or omitted for plain text. */ + lang?: string +} + +/** + * Whether `value` is a valid {@link FileTextLine} (defensive narrowing from + * opaque `meta`). `number` must be a 1-based integer line number, since a card + * rendered from a zero, fractional, or non-finite line number would violate the + * 1-based numbering contract the read window promises. + */ +function isFileTextLine(value: unknown): value is FileTextLine { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const { number, text } = value as Record + return typeof number === 'number' && Number.isInteger(number) && number >= 1 && typeof text === 'string' +} + +/** + * Narrow opaque live or replayed result metadata to a structured read window. + * Malformed metadata returns `undefined` so presentation can fall back to the + * generic text card instead of throwing during replay. Beyond shape, the + * semantic contract of a read window is enforced against replayed JSON that is + * well-typed but out of range: `offset` must be a 1-based integer, `totalLines` + * must be a non-negative integer, each line number must be a 1-based integer no + * less than `offset`, the line numbers must strictly increase, and no line number + * may exceed `totalLines`. Any violation declines to the generic fallback rather + * than emitting a card that misnumbers or overcounts. + * @param meta - result metadata. + * @returns the validated read window, or `undefined` for absent, malformed, or semantically invalid data. + */ +export function readMetaFromMeta(meta: unknown): FsReadMeta | undefined { + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined + const { path, offset, lines, totalLines, lang } = meta as Record + if (typeof path !== 'string' || typeof totalLines !== 'number' || typeof offset !== 'number') return undefined + if (!Number.isInteger(offset) || offset < 1) return undefined + if (!Number.isInteger(totalLines) || totalLines < 0) return undefined + if (!Array.isArray(lines) || !lines.every(isFileTextLine)) return undefined + if (lang !== undefined && typeof lang !== 'string') return undefined + let previous = offset - 1 + for (const { number } of lines) { + if (number <= previous || number > totalLines) return undefined + previous = number + } + return { path, offset, lines, totalLines, ...lang === undefined ? {} : { lang } } +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index 05e1b41ae2..a92fdcaad8 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -6,11 +6,11 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools' import { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' -import { buildWindow, formatReadOutput } from './read-render.ts' +import { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts' import { sessionResolveOptions } from './session-cwd.ts' /** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */ @@ -118,6 +118,19 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { }), }] }, + // Project the structured window into persisted `meta` so a UI's read card + // survives replay: the raw canonical output object is not on the wire, only + // the model-facing text, from which the line/lang data cannot be recovered. + presentationMeta: (_args, value) => { + const lang = langFromPath(value.path) + return { + path: value.path, + offset: value.offset, + lines: value.lines.map(({ number, text }) => ({ number, text })), + totalLines: value.totalLines, + ...lang === undefined ? {} : { lang }, + } + }, }, // Observation races fail closed because guarded mutations re-check the version in-lock. isConcurrencySafe: () => true, @@ -154,15 +167,32 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { ctx.emit('fs/observed', target, info.version, exec) return outcome }, - presentResult(_args, result: ToolResult): GenericResultView | undefined { + // Result-time display: a `read` card carrying the structured line window a + // capable UI renders as a line-numbered, syntax-highlighted view. The + // structured data is narrowed from the persisted `meta` (replay-safe); the + // envelope-stripped model-facing text rides along as `content` so a UI without + // the read capability still shows the file text. A malformed or absent meta, + // or a result whose text is not the read envelope, declines to `undefined` + // (the generic fallback), never throwing on replay of obsolete logged output. + presentResult(_args, result: ToolResult): ReadResultView | undefined { if (result.isError) return undefined + const meta = readMetaFromMeta(result.meta) + if (meta === undefined) return undefined const only = result.content.length === 1 ? result.content[0] : undefined const text = only?.type === 'text' ? only.text : undefined if (text === undefined) return undefined // Group 1 always captures (possibly empty) when the envelope matches. const body = /^[^\n]*<\/path>\nfile<\/type>\n\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1] if (body === undefined) return undefined - return { card: 'generic', content: [{ type: 'text', text: body }] } + return { + card: 'read', + path: meta.path, + offset: meta.offset, + lines: meta.lines, + totalLines: meta.totalLines, + ...meta.lang === undefined ? {} : { lang: meta.lang }, + content: [{ type: 'text', text: body }], + } }, // Pure display: a generic card titled by the file with the read window appended (`Read // foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index c2afaf002e..cc03a0c8f9 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' +import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' import type { ReadWindow } from '../src/read-render.ts' const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES } @@ -116,3 +116,103 @@ describe('buildWindow', () => { }) }) }) + +describe('langFromPath', () => { + it('maps a known extension to its language hint, case-insensitively', () => { + expect(langFromPath('src/a.ts')).toBe('ts') + expect(langFromPath('src/a.TSX')).toBe('tsx') + expect(langFromPath('/abs/module.mjs')).toBe('js') + expect(langFromPath('conf.yml')).toBe('yaml') + expect(langFromPath('README.md')).toBe('md') + }) + + it('reads the extension after the last path segment and last dot', () => { + expect(langFromPath('a.py.bak')).toBeUndefined() + expect(langFromPath('archive.tar.gz')).toBeUndefined() + expect(langFromPath('/dir.py/plain')).toBeUndefined() + expect(langFromPath('C:\\src\\main.rs')).toBe('rs') + }) + + it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => { + expect(langFromPath('.gitignore')).toBeUndefined() + expect(langFromPath('/etc/hosts')).toBeUndefined() + expect(langFromPath('data.unknownext')).toBeUndefined() + expect(langFromPath('trailingdot.')).toBeUndefined() + }) + + it('returns undefined for a filename whose extension is an Object.prototype key', () => { + // Own-property lookup only: these must not resolve to the inherited member + // (a function/object), which would fail the tool-output JSON validation. + expect(langFromPath('foo.constructor')).toBeUndefined() + expect(langFromPath('foo.__proto__')).toBeUndefined() + expect(langFromPath('foo.toString')).toBeUndefined() + expect(langFromPath('foo.hasOwnProperty')).toBeUndefined() + }) +}) + +describe('readMetaFromMeta', () => { + const good = { path: '/abs/a.ts', offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1, lang: 'ts' } + + it('narrows a well-formed read meta, with and without a lang hint', () => { + expect(readMetaFromMeta(good)).toEqual(good) + const noLang = { path: '/abs/a', offset: 1, lines: [], totalLines: 0 } + expect(readMetaFromMeta(noLang)).toEqual(noLang) + }) + + it('narrows an empty window at a positive offset (byte cap below the first selected line)', () => { + const empty = { path: '/abs/a', offset: 5, lines: [], totalLines: 9 } + expect(readMetaFromMeta(empty)).toEqual(empty) + }) + + it('returns undefined for absent, non-object, or array meta', () => { + expect(readMetaFromMeta(undefined)).toBeUndefined() + expect(readMetaFromMeta(null)).toBeUndefined() + expect(readMetaFromMeta('nope')).toBeUndefined() + expect(readMetaFromMeta([good])).toBeUndefined() + }) + + it('returns undefined when a field is missing or the wrong type (defensive narrowing)', () => { + expect(readMetaFromMeta({ ...good, path: 5 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, offset: '1' })).toBeUndefined() + expect(readMetaFromMeta({ ...good, totalLines: '1' })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: 'nope' })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: '1', text: 'x' }] })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: 1 }] })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined() + }) + + it('rejects an offset that is not a 1-based integer', () => { + expect(readMetaFromMeta({ ...good, offset: 0 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, offset: 1.5 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, offset: NaN })).toBeUndefined() + expect(readMetaFromMeta({ ...good, offset: Infinity })).toBeUndefined() + }) + + it('rejects a first line number below offset', () => { + expect(readMetaFromMeta({ ...good, offset: 2, lines: [{ number: 1, text: 'x' }], totalLines: 2 })).toBeUndefined() + }) + + it('rejects a line number that is not a 1-based integer', () => { + expect(readMetaFromMeta({ ...good, lines: [{ number: 0, text: 'x' }], totalLines: 1 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: 1.5, text: 'x' }], totalLines: 2 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: NaN, text: 'x' }], totalLines: 1 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: Infinity, text: 'x' }], totalLines: 1 })).toBeUndefined() + }) + + it('rejects a totalLines that is not a non-negative integer', () => { + expect(readMetaFromMeta({ ...good, totalLines: -1 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, totalLines: 1.5 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, totalLines: NaN })).toBeUndefined() + }) + + it('rejects lines that do not strictly increase or exceed totalLines', () => { + const twoLines = { path: '/abs/a', offset: 1, lang: 'ts' } + // Duplicate line numbers. + expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 1, text: 'a' }, { number: 1, text: 'b' }], totalLines: 2 })).toBeUndefined() + // Out-of-order line numbers. + expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 2, text: 'b' }, { number: 1, text: 'a' }], totalLines: 2 })).toBeUndefined() + // A line number past totalLines. + expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 3, text: 'c' }], totalLines: 2 })).toBeUndefined() + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index de844dcaf4..914a1bf7de 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -320,6 +320,39 @@ describe('read tool', () => { expect(text(result)).toContain('Output capped.') }) + it('attaches the structured window as presentation meta, and presentResult narrows it into a read card', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:a.ts', 'const x = 1\nconst y = 2') + const result = await call(ctx, 'read', { file_path: 'a.ts' }) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected read success') + // The extension drives the lang hint; the window rides on persisted meta. + expect(result.meta).toEqual({ + path: '/abs/a.ts', + offset: 1, + lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], + totalLines: 2, + lang: 'ts', + }) + const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result) + expect(view).toEqual({ + card: 'read', + path: '/abs/a.ts', + offset: 1, + lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], + totalLines: 2, + lang: 'ts', + content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }], + }) + }) + + it('omits the lang hint in meta for an extension that maps to no language', async () => { + const { ctx, fs } = await setup() + fs.files.set('key:notes', 'plain') + const result = await call(ctx, 'read', { file_path: 'notes' }) + if (result.isError) throw new Error('expected read success') + expect(result.meta).toEqual({ path: '/abs/notes', offset: 1, lines: [{ number: 1, text: 'plain' }], totalLines: 1 }) + }) }) describe('formatReadOutput footer variants', () => { @@ -450,33 +483,72 @@ describe('tool-owned presentation (pure presentCall)', () => { }) }) - it('read: completed presentation removes the model-facing XML envelope', async () => { - expect(await presentResult('read', { file_path: 'a.txt' }, { - content: [{ type: 'text', text: '/tmp/a.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n' }], + it('read: completed presentation is a read card carrying the structured window with the envelope stripped', async () => { + // The structured line data rides on persisted meta (the raw output object is + // not on the wire); presentResult narrows it and appends the stripped text as + // the no-capability `content` fallback. + const meta = { path: '/tmp/a.ts', offset: 1, lines: [{ number: 1, text: 'hello' }], totalLines: 1, lang: 'ts' } + expect(await presentResult('read', { file_path: 'a.ts' }, { + content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n' }], isError: false, + meta, })).toEqual({ - card: 'generic', + card: 'read', + path: '/tmp/a.ts', + offset: 1, + lines: [{ number: 1, text: 'hello' }], + totalLines: 1, + lang: 'ts', content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }], }) - expect(await presentResult('read', { file_path: 'a.txt' }, { + // A window whose extension maps to no language omits `lang` from the card. + expect(await presentResult('read', { file_path: 'notes' }, { + content: [{ type: 'text', text: '/tmp/notes\nfile\n\nbody\n' }], + isError: false, + meta: { path: '/tmp/notes', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 }, + })).toEqual({ + card: 'read', + path: '/tmp/notes', + offset: 1, + lines: [{ number: 1, text: 'body' }], + totalLines: 1, + content: [{ type: 'text', text: 'body' }], + }) + // Malformed envelope text with valid meta still declines (the fallback text is unavailable). + expect(await presentResult('read', { file_path: 'a.ts' }, { content: [{ type: 'text', text: 'malformed replay' }], isError: false, + meta, + })).toBeUndefined() + // Valid envelope but absent/malformed meta declines to the generic fallback. + expect(await presentResult('read', { file_path: 'a.ts' }, { + content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n' }], + isError: false, + })).toBeUndefined() + expect(await presentResult('read', { file_path: 'a.ts' }, { + content: [{ type: 'text', text: '/tmp/a.ts\nfile\n\n1: hello\n' }], + isError: false, + meta: { path: '/tmp/a.ts', lines: 'nope', totalLines: 1 }, })).toBeUndefined() }) it('read: completed presentation declines errors and non-single-text content', async () => { const envelope = '/tmp/a.txt\nfile\n\nbody\n' + const meta = { path: '/tmp/a.txt', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 } expect(await presentResult('read', { file_path: 'a.txt' }, { content: [{ type: 'text', text: envelope }], isError: true, + meta, })).toBeUndefined() expect(await presentResult('read', { file_path: 'a.txt' }, { content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }], isError: false, + meta, })).toBeUndefined() expect(await presentResult('read', { file_path: 'a.txt' }, { content: [{ type: 'reasoning', text: envelope }], isError: false, + meta, })).toBeUndefined() }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 02cf787a27..1bd0f19d0b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 73d8afb32f868ca82dfa2d350df089a5d0b9b358 -README.zh.md: 47af18f76302e261e18f682e0d3cf0ee903933db +README.md: 8f08d90f8a91afc2ff022761d2d83de055df18a0 +README.zh.md: febeba0e601d75cbc69490d29135c206189efe29 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 73d8afb32f..8f08d90f8a 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -22,19 +22,19 @@ Session model routing is a session-domain contract. `session.models` returns the Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. -Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. A stale continuation discards every partial result, deduplication entry, and cursor from that provider attempt, then restarts at the first page against the original list-derived visibility snapshot without discarding the learned provider page size. Limit probes and stale retries share the same limit of at most 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 request signal cancels persistence listing, cold-summary collection, and every search call, including a limit or stale rejection observed concurrently with cancellation. A deployment without the service, or any unrecovered index/query failure, also returns an `internal` business error so clients can retain metadata-only matches. -Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. +Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves exactly the namespaces a registered configurable provider addresses (`ctx.llm.listConfigurableProviders()`): the seam is general, but this plane is the model-provider surface, so a namespace nothing in the directory names is neither described nor writable here and answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired both by `llm/adapters-updated` and by a change to an exposed provider namespace, whose settings carry that provider's catalog and endpoint. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit Web-preference allowlist, currently only `permission`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 47af18f763..febeba0e60 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -22,19 +22,19 @@ 待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 陈旧的续传会丢弃该提供方尝试中的所有部分结果、去重条目和游标,然后依据最初从列表推导的可见性快照从第一页重新开始,但不会丢弃探测所得的提供方页面大小。上限探测与陈旧重试共用最多 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体请求信号可取消持久化列表枚举、冷会话摘要收集和每一次搜索调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。部署若未挂载该服务,或索引/查询故障无法恢复,也会返回 `internal` 业务错误,以便客户端保留仅基于元数据的匹配项。 -目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 +目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域只服务于已注册可配置提供方所指向的那些 namespace(`ctx.llm.listConfigurableProviders()`):seam 本身是通用的,但这个面是模型提供方表层,因此目录中无人点名的 namespace 在这里既不会被描述也不可写入,只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它既由 `llm/adapters-updated` 触发,也由某个已暴露提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 Web 偏好 allowlist,目前仅包含 `permission`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e224608c72..b6365a4a52 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -21,7 +21,7 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceNameConflictError, + WorkspaceMoveInvalidError, WorkspaceNameConflictError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' @@ -72,6 +72,9 @@ import { openNativePath } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 +/** Non-model settings namespaces intentionally served to the Web client. */ +const WEB_SETTINGS_NAMESPACES = ['permission'] as const + /** Provider work budget: at most 100 calls and 2,000 inspected hits. */ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 @@ -1098,31 +1101,35 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } - /** - * The settings namespaces this proxy serves: exactly those a registered - * configurable provider addresses. The settings seam itself is general — - * any plugin may register a namespace for its own configuration — but the - * Web configuration plane is scoped to model providers, and that boundary - * has to be enforced here rather than assumed from the current plugin set. - * Without it, every future `settings.register()` would silently become - * remotely readable and writable configuration. - */ - function exposedNamespaces(): Set { + /** Settings namespaces whose changes can invalidate the model catalog. */ + function modelProviderNamespaces(): Set { return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs)) } - /** Refuse a namespace outside the model-provider boundary, naming why. */ + /** + * The settings namespaces this proxy serves: configurable model providers + * plus the small explicit Web preference allowlist. The settings seam + * remains general; a future registration does not become remotely readable + * or writable by default. + */ + function exposedNamespaces(): Set { + const exposed = modelProviderNamespaces() + for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns) + return exposed + } + + /** Refuse a namespace outside the explicit configuration-client boundary. */ function notExposed(request: RpcRequest, ns: string): RpcResponse { return err(request, { code: 'settings-not-exposed', - message: `settings namespace "${ns}" is not exposed to configuration clients; only a namespace a registered model provider addresses is`, + message: `settings namespace "${ns}" is not exposed to configuration clients`, details: { ns }, }) } /** * Run one settings write (merge or wholesale replace) and acknowledge with - * the namespace's new redacted view. A namespace outside the model-provider + * the namespace's new redacted view. A namespace outside the configuration * boundary is refused before the seam is touched; every seam refusal — * unknown or invalid namespace, read-only provider, schema validation, * storage — becomes one `settings-rejected` carrying the seam's own message. @@ -1575,7 +1582,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro workspace: { list(request) { - return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) })) + return Promise.resolve(ok(request, { + items: ctx.workspace.list().map(workspaceView), + archivedSessionIds: [...ctx.workspace.archivedSessionIds], + })) }, // Exactly one of path/name arrives (schema refine). Existing-folder @@ -1691,6 +1701,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return ok(request, { workspace: workspaceView(workspace) }) }, + + async archiveSession(request) { + const { sessionId } = request.payload + try { + await ctx.workspace.archiveSession(sessionId) + } catch (error: unknown) { + // Only the registry's unknown-session rejection is the business + // code; storage/durability failures propagate as internal errors. + if (!(error instanceof WorkspaceUnknownSessionError)) throw error + return err(request, { + code: 'session-not-found', + message: error.message, + details: { sessionId }, + }) + } + return ok(request, { archivedSessionIds: [...ctx.workspace.archivedSessionIds] }) + }, }, host: { @@ -2102,6 +2129,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const committedWorkspaceIds = new Set( ctx.workspace.list().map(workspace => String(workspace.id)), ) + // Frame-dedup baseline, same posture as committedWorkspaceIds: the + // stream opens against the current set; workspace.list re-baselines + // reconnecting clients, so only later changes need frames. + let archivedSessionIds = ctx.workspace.archivedSessionIds const disposers = [ ctx.on('session/created', (session: Session) => { queue.push(frame({ @@ -2138,6 +2169,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro committedWorkspaceIds.add(workspaceId) queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) } + if (state.archivedSessionIds.length !== archivedSessionIds.length + || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { + archivedSessionIds = state.archivedSessionIds + queue.push(frame({ + type: 'host/archived-sessions-changed', + archivedSessionIds: [...state.archivedSessionIds], + })) + } return } if (change.table !== 'workspaces') return @@ -2165,11 +2204,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // inherited to overridden leaves the resolved value equal, and a // configuration client still has to re-read (its held revision is // stale, and the field's meaning changed). - queue.push(frame({ type: 'host/settings-changed', ns: String(ns) })) + const name = String(ns) + queue.push(frame({ type: 'host/settings-changed', ns: name })) // A provider's own settings carry its model catalog and endpoint, // so a change there invalidates the model list even when the route // set is untouched — `llm/adapters-updated` alone misses it. - if (exposedNamespaces().has(String(ns))) queue.push(frame({ type: 'host/models-changed' })) + if (modelProviderNamespaces().has(name)) queue.push(frame({ type: 'host/models-changed' })) }), ctx.on('credentials/updated', (ref) => { queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) })) diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 186c189879..ea4b6c892f 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -71,6 +71,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), + z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), z.object({ type: z.literal('host/commands-changed') }), z.object({ type: z.literal('host/settings-changed'), ns: z.string() }), z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index d678f2f911..d8ee3f6bff 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -101,7 +101,9 @@ export type MuxFrame = * workspace mutation (create/attach/order change — the client upserts, while * `workspace.list` provides the reconnect baseline); workspace-removed is the * committed registration-deletion increment and never implies directory or - * session-log deletion. + * session-log deletion; archived-sessions-changed pushes the full registry + * archive set after every durable change (same full-snapshot posture as + * workspace-changed — `workspace.list` re-baselines it on reconnect). */ export type HostFrame = | { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string } @@ -110,6 +112,7 @@ export type HostFrame = | { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/workspace-changed'; workspace: WorkspaceView } | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } + | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } /** * The command registry changed (`commands/change` passthrough). Pure * invalidation signal, no payload: clients refetch `command.list` in the diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index f2f112f7cd..88e2c05575 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -42,6 +42,7 @@ export interface RpcMethodMap { 'workspace.rename': WorkspaceApi['rename'] 'workspace.delete': WorkspaceApi['delete'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] + 'workspace.archiveSession': WorkspaceApi['archiveSession'] 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index e16e5339da..20b3038301 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -28,6 +28,7 @@ export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType>> /** workspace.create request payload: exactly one of path/name (the contract's create spellings). */ @@ -80,3 +81,13 @@ export const workspaceInsertSessionBeforeRequestSchema = z.object({ export const workspaceInsertSessionBeforeValueSchema = z.object({ workspace: workspaceViewSchema, }) satisfies z.ZodType>> + +/** workspace.archiveSession request payload. */ +export const workspaceArchiveSessionRequestSchema = z.object({ + sessionId: sessionIdSchema, +}) satisfies z.ZodType>> + +/** workspace.archiveSession response value: the full updated archive set. */ +export const workspaceArchiveSessionValueSchema = z.object({ + archivedSessionIds: z.array(sessionIdSchema), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index ff22d845fb..957c566bbd 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -37,8 +37,13 @@ export interface WorkspaceView { /** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */ export interface WorkspaceApi { - /** Lists all workspaces in the registry's durable display order. */ - list(request: RpcRequest<{}>): Promise> + /** + * Lists all workspaces in the registry's durable display order, plus the + * registry-global archive set (the reconnect baseline of + * `host/archived-sessions-changed`). Archived sessions stay in their + * workspace's `sessionIds` account; grouping surfaces hide them. + */ + list(request: RpcRequest<{}>): Promise> /** * Creates (or idempotently resolves) a workspace. Exactly one of `path` / @@ -86,4 +91,15 @@ export interface WorkspaceApi { sessionId: SessionId beforeSessionId?: SessionId }>): Promise> + + /** + * Adds one session to the registry-global archive set: the session + * disappears from every grouping surface but keeps its session log and its + * workspace accounting slot (a future unarchive restores its position). + * Idempotent for an already archived id. A session neither live nor in + * session persistence fails with `session-not-found`. Returns the full + * updated set (same snapshot the changed frame carries). + */ + archiveSession(request: RpcRequest<{ sessionId: SessionId }>): + Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 97037c10eb..7b5c1545de 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -31,6 +31,7 @@ import { sessionUpdateQueueValueSchema, } from '../api/sessions.schema.ts' import { + workspaceArchiveSessionValueSchema, workspaceCreateValueSchema, workspaceDeleteValueSchema, workspaceInsertSessionBeforeValueSchema, @@ -59,9 +60,10 @@ import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary * methods take the business payload directly — the carrier mints the rpcId and wraps the * envelope. Business code needing the call's rpcId reads it from the RpcResponse echo. - * Unary methods and respond accept an optional external AbortSignal as the last parameter - * (merged with the instance timeout via AbortSignal.any; same "signal rides beside the - * request, never on the wire" discipline as the stream signatures). + * Unary methods and respond accept an optional external AbortSignal as the last parameter. + * Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls + * carry only that external signal. In both cases the signal rides beside the request, never + * on the wire, like the stream signatures. * Stream methods accept an optional onOpen callback: it fires once the SSE transport is * readable (response headers received, before any frame) — the "stream established" signal * connection controllers need for the readiness handshake. Generators are lazy, so the @@ -97,6 +99,7 @@ export interface IApiClient { rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> + archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> } commands: { list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise>> @@ -162,6 +165,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType void>() - /** @param timeoutMs - unary timeout; streams never time out (long-lived by nature). */ + /** @param timeoutMs - timeout for bounded unary calls; user-paced calls and streams do not use it. */ constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */ @@ -257,15 +264,15 @@ export abstract class AbstractApiClient implements IApiClient { /** * Shared POST leg of both C→S carriers (callUnary/respond): JSON body, - * timeout merged with the caller's optional external signal, non-2xx → transport throw. + * optional default timeout merged with the caller's external signal, non-2xx → transport throw. */ private async postJson( path: string, body: ClientRequest | ClientResponse, signal: AbortSignal | undefined, - useDefaultTimeout = true, + timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise { - const requestSignal = useDefaultTimeout + const requestSignal = timeoutPolicy === 'default' ? signal === undefined ? AbortSignal.timeout(this.timeoutMs) : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) @@ -289,11 +296,11 @@ export abstract class AbstractApiClient implements IApiClient { method: K, payload: RequestPayload, signal?: AbortSignal, - useDefaultTimeout = true, + timeoutPolicy: UnaryTimeoutPolicy = 'default', ): Promise>> { const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload } this.onEnvelope(message) - const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout) + const response = await this.postJson(`/api/${method}`, message, signal, timeoutPolicy) const full = serverResponseSchema.parse(await response.json()) this.onEnvelope(full) if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`) @@ -382,7 +389,9 @@ export abstract class AbstractApiClient implements IApiClient { describe: (payload, signal) => this.callUnary('host.describe', payload, signal), // A native system dialog is user-paced and may legitimately stay open // longer than the normal unary deadline. Caller/connection aborts remain. - pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false), + pickDirectory: (payload, signal) => this.callUnary( + 'host.pickDirectory', payload, signal, 'caller-signal-only', + ), listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal), createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal), openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal), @@ -394,11 +403,16 @@ export abstract class AbstractApiClient implements IApiClient { rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), + archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), } readonly commands: IApiClient['commands'] = { list: (payload, signal) => this.callUnary('command.list', payload, signal), - execute: (payload, signal) => this.callUnary('command.execute', payload, signal), + // Command handlers are user-driven operations and may legitimately exceed + // the transport health deadline. Caller/connection aborts remain. + execute: (payload, signal) => this.callUnary( + 'command.execute', payload, signal, 'caller-signal-only', + ), } readonly skills: IApiClient['skills'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 0cf7625a65..6bc060e969 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -33,6 +33,7 @@ import { hostPickDirectoryRequestSchema, } from '../api/host.schema.ts' import { + workspaceArchiveSessionRequestSchema, workspaceCreateRequestSchema, workspaceDeleteRequestSchema, workspaceInsertSessionBeforeRequestSchema, @@ -96,6 +97,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, + 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index a505f72018..08d2dee2f3 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -160,8 +160,8 @@ async function harness(options?: { await ctx.plugin(LlmService) if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings) if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials) - // The proxy serves only namespaces a configurable provider addresses, which - // is what the real LLM plugins declare at load; the tests mirror that. + // Model-provider namespaces and the explicit Web preference allowlist are + // the proxy's complete settings surface. if (options?.configurableProviders !== false) { ctx.llm.registerConfigurableProviders([ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, @@ -222,19 +222,29 @@ describe('settings domain', () => { expect(JSON.stringify(value)).not.toContain('user-secret') }) - it('serves only namespaces a registered model provider addresses', async () => { + it('serves model-provider and explicitly allowlisted Web namespaces only', async () => { // The settings seam is general: any plugin may register a namespace for - // its own configuration. The Web configuration plane is not — it is the - // model-provider surface, and a namespace nothing in the provider - // directory addresses must be invisible and unwritable here, so a future - // plugin cannot become remotely configurable just by registering. + // its own configuration. The Web configuration plane remains opt-in, so a + // future internal plugin cannot become remotely configurable just by + // registering; permission is the one non-model namespace intentionally + // admitted by this surface. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) + ctx.settings.register(settingsNamespace('permission'), z.object({ + defaultPreset: z.union(['read-only', 'workspace-write']).required(), + }), { + base: { defaultPreset: 'read-only' }, + }) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.settings.describe(request({}))) - expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek']) + expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission']) + const permission = expectOk(await api.settings.mutate(request({ + ns: 'permission', + ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], + }))) + expect(permission.value).toEqual({ defaultPreset: 'workspace-write' }) for (const response of [ await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })), @@ -277,6 +287,20 @@ describe('settings domain', () => { .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) }) + it('broadcasts a permission change without invalidating the model catalog', async () => { + const ctx = await harness() + const permission = ctx.settings.register(settingsNamespace('permission'), z.object({ + defaultPreset: z.union(['read-only', 'workspace-write']).required(), + }), { + base: { defaultPreset: 'read-only' }, + }) + const api = createApiProxy(ctx, DEFAULTS) + const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 1, async () => { + await permission.update({ defaultPreset: 'workspace-write' }) + }) + expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }]) + }) + it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index f520f8766a..d9211dde6c 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -441,4 +441,44 @@ describe('Host Workspace increments', () => { expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) abort.abort() }) + + it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { + const { api } = await harness() + const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace + const sessionId = SessionId('session-to-archive') + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) + expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) + + const abort = new AbortController() + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + const changed = nextHostFrame(stream) + expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) + .toEqual([sessionId]) + expect(await changed).toMatchObject({ + payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] }, + }) + + // Accounting and the session itself are untouched; list re-baselines the set. + const listed = expectOk(await api.workspace.list(request({}))) + expect(listed.archivedSessionIds).toEqual([sessionId]) + expect(listed.items[0]?.sessionIds).toEqual([sessionId]) + expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) + + // The idempotent repeat emits no second frame: the next observed frame is + // the workspace-changed of a later attach, not another archive snapshot. + const after = nextHostFrame(stream) + expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) + .toEqual([sessionId]) + const otherSession = SessionId('session-after-archive') + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession }))) + expect((await after).payload.type).not.toBe('host/archived-sessions-changed') + + const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') })) + expect(missing.result).toMatchObject({ + ok: false, + error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } }, + }) + abort.abort() + }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 1a667bf4a6..6307dfe8f9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -66,11 +66,12 @@ function scriptedApi(overrides: { ...overrides.host, }, workspace: { - list: r => ok(r, { items: [] }), + list: r => ok(r, { items: [], archivedSessionIds: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), delete: r => ok(r, { deleted: true as const }), insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), + archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), }, commands: { list: r => ok(r, { commands: [] }), @@ -360,10 +361,12 @@ describe('workspace domain round trip', () => { it('routes both workspace methods through their handler rows and value schemas', async () => { const c = client(scriptedApi()) const list = await c.workspace.list({}) - expect(list.result).toEqual({ ok: true, value: { items: [] } }) + expect(list.result).toEqual({ ok: true, value: { items: [], archivedSessionIds: [] } }) const created = await c.workspace.create({ path: '/t' }) expect(created.result.ok).toBe(true) if (created.result.ok) expect(created.result.value.created).toBe(true) + const archivedResponse = await c.workspace.archiveSession({ sessionId: 's-arch' as never }) + expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) }) it('rejects a create payload violating the exactly-one refine at the handler', async () => { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 2390d628ac..ef111afe12 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -122,7 +122,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, workspace: { async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } + return { rpcId: request.rpcId, result: { ok: true, value: { items: [], archivedSessionIds: [] } } } }, async create(request) { return { @@ -145,6 +145,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, } }, + async archiveSession(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } } + }, }, commands: { async list(request) { @@ -349,6 +352,68 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } }) }) + it('lets command.execute finish after the 30-second default unary deadline', async () => { + vi.useFakeTimers() + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => { + const controller = new AbortController() + setTimeout(() => { + controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError')) + }, milliseconds) + return controller.signal + }) + try { + const api = fakeApi() + api.commands.execute = async (request) => { + await new Promise(resolve => setTimeout(resolve, 30_001)) + return { + rpcId: request.rpcId, + result: { ok: true, value: { matched: true, commandId: CommandId('cmd-slow') } }, + } + } + const execution = client(api).commands.execute({ sessionId: 's' as never, line: '/slow' }) + const assertion = expect(execution).resolves.toMatchObject({ + result: { ok: true, value: { matched: true, commandId: 'cmd-slow' } }, + }) + + await Promise.all([ + vi.advanceTimersByTimeAsync(30_001), + assertion, + ]) + expect(timeoutSpy).not.toHaveBeenCalled() + } finally { + timeoutSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('keeps caller and connection aborts on command.execute', async () => { + const api = fakeApi() + const started = Promise.withResolvers() + api.commands.execute = async (request, signal) => { + started.resolve(signal) + if (!signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + return { + rpcId: request.rpcId, + result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } }, + } + } + const controller = new AbortController() + const execution = client(api).commands.execute( + { sessionId: 's' as never, line: '/hang' }, + controller.signal, + ) + const handlerSignal = await started.promise + + controller.abort(new Error('connection closed')) + + await expect(execution).rejects.toThrow('connection closed') + expect(handlerSignal.aborted).toBe(true) + }) + it('propagates the carrier Request signal into command.execute', async () => { const handler = toFetchHandler(fakeApi()) const controller = new AbortController() diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index aa9c46d9ae..f6bd093178 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -20,6 +20,7 @@ import { hostListDirectoryRequestSchema, hostListDirectoryValueSchema, } from '../src/api/host.schema.ts' import { + workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, @@ -312,7 +313,16 @@ describe('workspace domain schemas', () => { expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1']) expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow() expect(workspaceListRequestSchema.parse({})).toEqual({}) - expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1) + expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1) + expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow() + }) + + it('archiveSession request/value carry the id and the full updated set', () => { + expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') + expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow() + expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds) + .toEqual(['s1', 's2']) + expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) it('create requires exactly one of path/name (both refine arms)', () => { diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml index f6f5f49a14..3e7d9db68b 100644 --- a/packages/ui/permission/README.i18n.yaml +++ b/packages/ui/permission/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/ui/permission/README.md -README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4 -README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa +README.md: 4f7f560bb81eaad3b6b95b2742432fa252682d5a +README.zh.md: 79d0ce9c095d3426f3219f04d9cb7ec3b161a184 diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 814085ed6f..4f7f560bb8 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -6,7 +6,9 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s `set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. -The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). +The service owns the `permission` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permission/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent. + +The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. @@ -23,3 +25,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet. - **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service. - **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin. +- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset. diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md index 36880d6b8c..79d0ce9c09 100644 --- a/packages/ui/permission/README.zh.md +++ b/packages/ui/permission/README.zh.md @@ -6,7 +6,9 @@ `set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 -该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。 + +该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元折叠三个全量值旋钮事件,在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 @@ -23,3 +25,4 @@ - **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 - **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。 - **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。 +- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。 diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index c5021af37b..c3554e3c4d 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-projection": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 5c18a2b560..d7f0dcc391 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -21,6 +21,7 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a import type {} from '@deepseek-ai/dsh-bash' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' // Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children. import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-commands' @@ -68,6 +69,9 @@ export interface PresetSpec { */ export const CUSTOM_PRESET = 'custom' +/** Settings namespace carrying the default for future sessions. */ +export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission') + /** * Fold the last selected preset from the durable log; replay needs no catch-up * state. @@ -126,7 +130,13 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState { return state } -/** The {@link PermissionService} config: the deployment's preset table. */ +/** User setting resolved when a new session receives its initial permission. */ +export interface PermissionSettings { + /** Preset pinned into a newly created session. */ + defaultPreset: string +} + +/** The {@link PermissionService} config: preset table and composition default. */ export interface Config { /** * The preset table: name → knob bundle. Defaults to `workspace-write` @@ -134,6 +144,11 @@ export interface Config { * never). The name `custom` is reserved for the derived not-a-preset state. */ presets?: Record + /** + * Default for new sessions. When omitted, the preset matching the composed + * sandbox and approval defaults is used. + */ + defaultPreset?: string } /** @@ -159,11 +174,13 @@ export class PermissionService extends Service { name: 'danger-full-access', description: 'Full file access without approval prompts.', }, }), + defaultPreset: z.string(), }) - static inject = ['bash', 'approval'] + static inject = ['bash', 'approval', 'sessions'] private readonly presets: Record + private defaultSettings: () => PermissionSettings constructor(ctx: Context, config: Config) { super(ctx, 'permission') @@ -175,6 +192,37 @@ export class PermissionService extends Service { if (ctx.bash.sandboxMode === undefined) { throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration') } + const inferredDefault = this.derive(EMPTY_KNOBS) + const defaultPreset = config.defaultPreset ?? inferredDefault + if (defaultPreset === CUSTOM_PRESET) { + throw new Error('permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly') + } + this.resolve(defaultPreset) + const baseSettings: PermissionSettings = { defaultPreset } + this.defaultSettings = () => baseSettings + const presetChoices = this.names.map((name) => { + const choice = z.const(name) + const label = this.presets[name]?.name + return label === undefined ? choice : choice.description(label) + }) + const settingsSchema: z = z.object({ + defaultPreset: z.union(presetChoices).required(), + }) + installSettingsSection(ctx, PERMISSION_SETTINGS_NAMESPACE, settingsSchema, baseSettings, { + setSource: (current) => { + this.defaultSettings = current + }, + // The source thunk reads the latest scope snapshot at session creation; + // no process-level registration needs replacement on change. + onChange: () => {}, + }) + + ctx.on('session/created', (session) => { + this.pinInitialPermission(session) + }) + for (const session of ctx.sessions.list()) { + this.pinInitialPermission(session) + } // The permissions projection unit: fold the three whole-value knob // events; view derives the select over the composition defaults this @@ -237,6 +285,15 @@ export class PermissionService extends Service { return Object.keys(this.presets) } + /** + * The preset currently selected as the default for future sessions. + * @returns the resolved settings value, or the composition default without + * a mounted settings provider. + */ + get defaultPreset(): string { + return this.defaultSettings().defaultPreset + } + /** * Resolve the preset matching the effective knob values. A still-matching * last selection wins shared-bundle ties; otherwise the first table match @@ -328,6 +385,44 @@ export class PermissionService extends Service { setApprovalPolicy(session, spec.approval) } } + + /** + * Fill every missing permission fact before a session is published. A + * genuinely fresh session uses the current user default; seeded or partially + * initialized sessions preserve their effective knob values and only gain + * the missing durable facts. + */ + private pinInitialPermission(session: Session): void { + const events = session.events + const selected = effectivePermissionPreset(events) + const sandbox = effectiveSandboxMode(events) + const approval = effectiveApprovalPolicy(events) + const seeded = events.some(event => event.type === 'session/end-seed') + if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) { + const name = this.defaultPreset + const spec = this.resolve(name) + session.append('permission/preset', { preset: name }) + setSandboxMode(session, spec.sandbox) + setApprovalPolicy(session, spec.approval) + return + } + + const state: KnobState = { + preset: selected ?? null, + sandbox: sandbox ?? null, + approval: approval ?? null, + } + const effective = this.derive(state) + if (selected === undefined && effective !== CUSTOM_PRESET) { + session.append('permission/preset', { preset: effective }) + } + if (sandbox === undefined) { + setSandboxMode(session, this.ctx.bash.sandboxMode as SandboxMode) + } + if (approval === undefined) { + setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask') + } + } } export default PermissionService diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 05a747de8d..0d80b4533b 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -1,10 +1,29 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' -import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission' +import PermissionService, { + CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE, +} from '@deepseek-ai/dsh-permission' import type { Config } from '@deepseek-ai/dsh-permission' +import { Settings } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' + +/** Writable memory provider for the permission/settings lifecycle specs. */ +class MemorySettings extends Settings { + readonly doc: Record = {} + readonly writable = true + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc[ns] = structuredClone(section) + return Promise.resolve() + } +} async function mounted(options: { config?: Config @@ -12,6 +31,7 @@ async function mounted(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise { const ctx = new Context() + await ctx.plugin(SessionStore) ctx.provide('bash', { sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write', resolve() { throw new Error('permission tests do not execute bash') }, @@ -27,6 +47,23 @@ function freshSession(id: string): Session { return new Session(SessionId(id)) } +async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemorySettings) + ctx.provide('bash', { + sandboxMode: 'workspace-write', + resolve() { throw new Error('permission tests do not execute bash') }, + run() { throw new Error('permission tests do not execute bash') }, + start() { throw new Error('permission tests do not execute bash') }, + }) + ctx.provide('approval', { + config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' }, + }) + await ctx.plugin(PermissionService, {}) + return ctx +} + describe('effectivePermissionPreset', () => { it('folds to the last event, or undefined without one', () => { const session = freshSession('sess-fold') @@ -66,8 +103,11 @@ describe('PermissionService', () => { expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/) }) - it('composition defaults outside the table derive custom at zero events', async () => { - const ctx = await mounted({ approvalDefault: 'never' }) + it('composition defaults outside the table still derive custom when an explicit new-session default is configured', async () => { + const ctx = await mounted({ + approvalDefault: 'never', + config: { defaultPreset: 'workspace-write' }, + }) const session = freshSession('sess-defaults-custom') expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) }) @@ -138,6 +178,11 @@ describe('PermissionService', () => { .rejects.toThrow(/reserved for the derived not-a-preset state/) }) + it('requires an explicit default when composition defaults match no preset', async () => { + await expect(mounted({ approvalDefault: 'never' })) + .rejects.toThrow(/configure defaultPreset explicitly/) + }) + it('reads a schema-less approval stand-in as the ask default', async () => { const ctx = await mounted({ approvalDefault: undefined }) const session = freshSession('sess-standin') @@ -146,3 +191,111 @@ describe('PermissionService', () => { expect(ctx.permission.current(session.events)).toBe('workspace-write') }) }) + +describe('new-session default', () => { + it('pins the current setting into each new session without changing earlier sessions', async () => { + const ctx = await mountedStore() + const first = ctx.sessions.create(SessionId('first')) + expect(first.events.map(event => [event.type, event.data])).toEqual([ + ['permission/preset', { preset: 'workspace-write' }], + ['sandbox/mode', { mode: 'workspace-write' }], + ['approval/policy', { policy: 'ask' }], + ]) + + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + expect(ctx.permission.defaultPreset).toBe('danger-full-access') + const second = ctx.sessions.create(SessionId('second')) + expect(ctx.permission.current(first.events)).toBe('workspace-write') + expect(ctx.permission.current(second.events)).toBe('danger-full-access') + expect(second.events.map(event => event.type)).toEqual([ + 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + }) + + it('preserves a seeded legacy session instead of applying the latest user default', async () => { + const ctx = await mountedStore() + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + const legacy = freshSession('legacy-source') + legacy.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events }) + expect(ctx.permission.current(resumed.events)).toBe('workspace-write') + expect(resumed.events.slice(-3).map(event => event.type)).toEqual([ + 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + }) + + it('preserves composition defaults when an empty stored session resumes', async () => { + const ctx = await mountedStore() + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] }) + expect(ctx.permission.current(resumed.events)).toBe('workspace-write') + expect(resumed.events.map(event => event.type)).toEqual([ + 'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + }) + + it('pins sessions that already exist when the service remounts', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.provide('bash', { + sandboxMode: 'workspace-write', + resolve() { throw new Error('permission tests do not execute bash') }, + run() { throw new Error('permission tests do not execute bash') }, + start() { throw new Error('permission tests do not execute bash') }, + }) + ctx.provide('approval', { config: { policy: 'ask' } }) + const existing = ctx.sessions.create(SessionId('existing-before-permission')) + expect(existing.events).toEqual([]) + + await ctx.plugin(PermissionService, {}) + expect(existing.events.map(event => event.type)).toEqual([ + 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + expect(ctx.permission.current(existing.events)).toBe('workspace-write') + }) + + it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => { + const ctx = await mountedStore() + const partial = freshSession('partial-source') + partial.append('sandbox/mode', { mode: 'workspace-write' }) + partial.append('approval/policy', { policy: 'ask' }) + const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.events }) + expect(resumed.events.at(-1)).toMatchObject({ + type: 'permission/preset', + data: { preset: 'workspace-write' }, + }) + + const custom = freshSession('custom-source') + custom.append('sandbox/mode', { mode: 'read-only' }) + custom.append('approval/policy', { policy: 'never' }) + const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events }) + expect(ctx.permission.current(unmatched.events)).toBe(CUSTOM_PRESET) + expect(unmatched.events.at(-1)?.type).toBe('session/end-seed') + }) + + it('materializes ask when a legacy seed and approval stand-in omit the policy', async () => { + const ctx = await mountedStore({ approvalDefault: undefined }) + const partial = freshSession('approval-fallback-source') + partial.append('sandbox/mode', { mode: 'workspace-write' }) + const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.events }) + expect(resumed.events.at(-1)).toMatchObject({ + type: 'approval/policy', + data: { policy: 'ask' }, + }) + }) + + it('rejects a stored default outside the configured preset table', async () => { + const ctx = await mountedStore() + await expect(ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'missing', + })).rejects.toThrow() + expect(ctx.permission.defaultPreset).toBe('workspace-write') + }) +}) diff --git a/packages/ui/permission/tests/projection.spec.ts b/packages/ui/permission/tests/projection.spec.ts index 1649fe7077..a50c17a399 100644 --- a/packages/ui/permission/tests/projection.spec.ts +++ b/packages/ui/permission/tests/projection.spec.ts @@ -44,7 +44,7 @@ async function agentFor(ctx: Context, session: Session): Promise { } describe('permissions projection unit', () => { - it('serves the composition-default select at zero events', async () => { + it('serves the pinned new-session default select', async () => { const { ctx, session } = await harness() const value = ctx.sessionProjections.snapshot(session).values.permissions expect(value).toMatchObject({ currentValue: 'workspace-write' }) @@ -103,12 +103,14 @@ describe('/permission command', () => { kind: 'success', text: 'current preset workspace-write (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0) + expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(1) }) it('rejects an unknown preset without touching the log', async () => { const { ctx, session } = await harness() const agent = await agentFor(ctx, session) + const before = session.events.filter(event => + event.type !== 'command/run' && event.type !== 'command/done') const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal) // The error text carries the same no-self-labelling rule as the success // texts: `permission · unknown preset "yolo" (…)`, not `unknown permission @@ -117,6 +119,7 @@ describe('/permission command', () => { kind: 'error', text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0) + expect(session.events.filter(event => + event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before) }) }) diff --git a/packages/ui/permission/tsconfig.json b/packages/ui/permission/tsconfig.json index 493fbf358e..9dc4afcd9a 100644 --- a/packages/ui/permission/tsconfig.json +++ b/packages/ui/permission/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../settings/settings" + }, { "path": "../commands" } diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 15a3ec1fab..c8ba061166 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -52,15 +52,28 @@ function pretty(value: unknown): string { return displayText(serialized ?? String(value)) } +/** + * A side's content lines under the terminator rule the Web DiffBlock also + * applies: empty text is zero lines (a full deletion's `newText`, a create's + * absent `oldText`), and a single trailing newline terminates the last line + * rather than adding an empty one. An interior blank line survives. Keeping the + * two front ends on the same rule holds their `+A -R` footers in step. + */ +function diffContentLines(text: string): string[] { + if (text === '') return [] + const body = text.endsWith('\n') ? text.slice(0, -1) : text + return body.split('\n') +} + /** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */ function diffLines(diff: FileDiff, palette: Palette): string[] { // The card header is a fixed `Tool / ` frame that never names a file, so // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] if (diff.oldText !== null) { - for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.error(`- ${line}`)) + for (const line of diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`)) } - for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`)) + for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`)) return lines } @@ -389,14 +402,16 @@ export class ToolCardComponent implements Component { const glyph = this.result === undefined ? '○' : '●' const rawBody = this.renderBody() const view = this.resultView ?? this.callView - // A generic card's own content, or a search/web card's fallback to the raw - // result content (neither the `search` nor the `web` view carries a `content` - // copy), all render as one dim Markdown block below, so links/lists/headings - // keep the unified dim styling rather than reading as bare text. A search card - // thus stays byte-identical to the pre-search-card generic fallback. Terminal - // and diff cards own their body styling, so they are excluded (mirrors - // renderBody's post-terminal/diff fallback). - const markdownContent = view.card === 'generic' + // A generic card's own content, a read card's `content` fallback (the + // envelope-stripped file text — the TUI has no dedicated read rendering, so a + // read renders exactly as before the read card existed), or a search/web + // card's fallback to the raw result content (neither the `search` nor the + // `web` view carries a `content` copy), all render as one dim Markdown block + // below, so links/lists/headings keep the unified dim styling rather than + // reading as bare text. A search card thus stays byte-identical to the + // pre-search-card generic fallback. Terminal and diff cards own their body + // styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback). + const markdownContent = view.card === 'generic' || view.card === 'read' ? view.content ?? this.result?.content : view.card === 'search' ? this.result?.content @@ -505,25 +520,31 @@ export class ToolCardComponent implements Component { } if (view.card === 'diff') { // The header no longer names the file, so each diff keeps its own path - // header. A trailing footer summarizes the change (`+A -R · N file(s)`). + // header. A trailing footer summarizes the change (`+A -R · N file(s)`), + // on the same terminator rule and distinct-path count the Web DiffBlock + // uses, so the two front ends' footers agree. let added = 0 let removed = 0 + const paths = new Set() const hunks = view.diffs.flatMap((diff, index) => { - if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length - added += displayText(diff.newText).split('\n').length + paths.add(diff.path) + if (diff.oldText !== null) removed += diffContentLines(displayText(diff.oldText)).length + added += diffContentLines(displayText(diff.newText)).length return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)] }) - const files = view.diffs.length + const files = paths.size const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) // A diff's own `+`/`-` colors carry its meaning, so it renders verbatim // rather than under the dim result-output color. return { prelude: [...hunks, footer], lines: [] } } - // Neither a search card nor a web card carries a `content` copy, so those - // result views fall back to the raw result content here (`view.card === - // 'generic'` narrows the generic union arm; a search or web card takes the - // same fallback, mirroring the `markdownContent` selection in render()). - const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content + // A generic or read card carries its own envelope-stripped `content`; a + // search or web card carries no `content` copy and falls back to the raw + // result content here. (Mirrors the `markdownContent` selection in render(); + // a read card has no dedicated TUI rendering, so its `content` takes the same + // body path, keeping read output as it was before the read card existed, and + // a search card stays byte-identical to the pre-search-card fallback.) + const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content const prelude: string[] = [] const lines: string[] = [] // The presenter title headlines the body now that the header is a fixed diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 423f6dff5f..8ba1af9c50 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4318,6 +4318,25 @@ describe('tool cards and surface replay', () => { diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }], }), }, + scatteredDiff: { + name: 'scatteredDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + // Three hunks in ONE file. The first two sides end in the terminator + // newline real write/edit content carries; the third removes a line and + // leaves an EMPTY added side (a full deletion), so `diffContentLines('')` + // returns zero lines. The footer must read `+2 -1 · 1 file`: each trailing + // newline terminates its line rather than adding a phantom empty one, the + // empty side contributes no `+ ` row, and the three hunks count as the + // single distinct path they touch. + presentCall: () => ({ + card: 'diff', + title: 'Edit src/scatter.ts', + diffs: [ + { path: 'src/scatter.ts', oldText: null, newText: 'first\n' }, + { path: 'src/scatter.ts', oldText: null, newText: 'second\n' }, + { path: 'src/scatter.ts', oldText: 'gone\n', newText: '' }, + ], + }), + }, generic: { name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }), @@ -4672,6 +4691,35 @@ describe('tool cards and surface replay', () => { await dispose(result) }) + it('counts a same-file diff once and terminates its trailing newline', async () => { + // A budget past the card's row count so every hunk row stays visible (the + // collapse arithmetic is covered elsewhere); this test is about the + // terminator rule and the distinct-path footer count. + const result = await setup({ tools, config: { maxToolOutputLines: 20 } }) + appendUser(result.session, 'scatter edits in one file') + appendAssistant(result.session, [ + { type: 'text', text: 'Editing' }, + { type: 'tool-call', id: 'scatter' as never, name: 'scatteredDiff', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, step: 1, callId: 'scatter' as never, name: 'scatteredDiff', arguments: '{}', + }) + await tick() + const output = result.terminal.output + // Three hunks, one path: distinct-path count, same as the Web DiffBlock. + expect(output).toContain('· 1 file') + expect(output).not.toContain('· 3 files') + // The `first\n`/`second\n` sides each contribute exactly one added line — + // the trailing newline terminates rather than adding a phantom empty `+ `. + expect(output).toContain('+ first') + expect(output).toContain('+ second') + // The third hunk removes `gone` and leaves an empty added side, which + // contributes no `+ ` row (diffContentLines('') is zero lines). + expect(output).toContain('- gone') + expect(output).toContain('+2 -1') + await dispose(result) + }) + it('drops blank rows from a terminal card result that the dim styling wraps', async () => { const blankRowTools: Record = { trailing: { diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index a24e81d977..33f0029093 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/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/workspace/workspace/README.md -README.md: bee3e4fcb5dded273f30942ee2e42ee93b839e62 -README.zh.md: 9a052f796fb7cc8756999bdc9b2ce905805730ab +README.md: 11dc8172392e530ab4ea16f1b60473e5befb8089 +README.zh.md: 5c6e3cefe31759df27b8008861505527648ce3c4 diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index bee3e4fcb5..11dc817239 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -12,7 +12,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n - `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it. - `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity. - `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. -- `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes. +- `ctx.workspace.archiveSession(id)` / `archivedSessionIds` — the registry-global archive set, layered over workspace accounting: an archived session disappears from grouping surfaces but keeps its session log and its `sessionIds` slot, so a future unarchive restores its position. Archiving accepts any live or persisted session (accounted or Ungrouped), resolves without writing for an already archived id, and rejects an unknown id. State written before the field existed parses with an empty set. - `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup. - `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 9a052f796f..5c6e3cefe3 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 - `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。 - `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 -- `ctx.workspace.touchSession(id)`:仅将已验证、已记账的会话移到最前。未分组或被过滤的会话不会触发任何操作,workspace 顺序绝不改变。 +- `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。 - `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 - `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 5172c63805..5262043b03 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -49,6 +49,20 @@ export class WorkspaceNameConflictError extends Error { } } +/** + * An archiveSession request named a session neither live nor in session + * persistence — a definite miss only; storage faults propagate as themselves. + */ +export class WorkspaceUnknownSessionError extends Error { + /** + * @param sessionId - The unknown session id. + */ + constructor(readonly sessionId: SessionId) { + super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`) + this.name = 'WorkspaceUnknownSessionError' + } +} + declare module 'cordis' { interface Context { @@ -181,6 +195,49 @@ export class WorkspaceRegistry extends Service { return this.enqueueOperation(() => this.deleteKnown(id)) } + /** + * The registry-global archive set: sessions hidden from every grouping + * surface. Archiving never touches workspace accounting — an archived + * session keeps its `sessionIds` slot so unarchiving restores its position. + * @returns the archived session ids in archive order. + */ + get archivedSessionIds(): readonly SessionId[] { + return this.requireState().archivedSessionIds + } + + /** + * Archive one session durably. The session must exist (live or in session + * persistence); its workspace accounting — or lack of one — is irrelevant. + * An already archived id resolves without writing. + * @param sessionId - The session to archive. + * @returns resolution after durability. + */ + archiveSession(sessionId: SessionId): Promise { + return this.enqueueOperation(async () => { + // The chain slot serializes against every other registry write, so this + // check-then-write pair cannot interleave with another archive. + if (this.requireState().archivedSessionIds.includes(sessionId)) return + if (!(await this.sessionKnown(sessionId))) { + throw new WorkspaceUnknownSessionError(sessionId) + } + const state = this.requireState() + await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] }) + }) + } + + /** + * Whether a session is live, header-indexed, or present in a fresh + * persistence listing. Only a definite miss returns false — a failing + * `sessionPersistence.list()` propagates so storage faults never + * masquerade as an unknown session. + */ + private async sessionKnown(id: SessionId): Promise { + if (this.ctx.get('sessions')?.get(id) !== undefined) return true + if (this.headers.has(id)) return true + await this.indexHeaders(await this.ctx.sessionPersistence.list()) + return this.headers.has(id) + } + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned @@ -245,7 +302,11 @@ export class WorkspaceRegistry extends Service { } try { - await this.setState({ initialized: true, workspaceIds: [id, ...state.workspaceIds] }) + await this.setState({ + initialized: true, + workspaceIds: [id, ...state.workspaceIds], + archivedSessionIds: state.archivedSessionIds, + }) } catch (error) { this.entities.delete(id) try { @@ -276,6 +337,7 @@ export class WorkspaceRegistry extends Service { const nextState = { initialized: true, workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), + archivedSessionIds: state.archivedSessionIds, } await this.setState({ ...nextState, @@ -329,7 +391,11 @@ export class WorkspaceRegistry extends Service { ) } await this.requireTable().delete(pending.workspaceId) - await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds }) + await this.setState({ + initialized: state.initialized, + workspaceIds: state.workspaceIds, + archivedSessionIds: state.archivedSessionIds, + }) } private async bootstrap(headers: readonly SessionHeader[]): Promise { @@ -411,9 +477,9 @@ export class WorkspaceRegistry extends Service { .map(([id]) => id) if (!sameIds(state.workspaceIds, workspaceIds)) { - await this.setState({ initialized: false, workspaceIds }) + await this.setState({ initialized: false, workspaceIds, archivedSessionIds: state.archivedSessionIds }) } - await this.setState({ initialized: true, workspaceIds }) + await this.setState({ initialized: true, workspaceIds, archivedSessionIds: state.archivedSessionIds }) } private validateStoredState(state: WorkspaceDomainState): void { diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts index 7b1a6a41d0..ba1b89e39d 100644 --- a/packages/workspace/workspace/src/spec.ts +++ b/packages/workspace/workspace/src/spec.ts @@ -42,11 +42,16 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [ /** * Durable registry state. `initialized` distinguishes a valid empty registry * from one that still needs the header-only history bootstrap; - * `workspaceIds` is the authoritative display order. + * `workspaceIds` is the authoritative display order. `archivedSessionIds` is + * the registry-global archive set layered over workspace accounting: an + * archived session keeps its `sessionIds` slot (unarchiving must restore the + * position), so the set never participates in the one-owner accounting + * invariant. Defaulted so records written before the field parse unchanged. */ export const workspaceDomainState = z.object({ initialized: z.boolean(), workspaceIds: z.array(workspaceId), + archivedSessionIds: z.array(z.string().transform(SessionId)).default([]), pendingMutation: workspacePendingMutation.optional(), }) @@ -64,7 +69,7 @@ export const workspaceDomainSpec = defineDomain({ version: 2, global: { schema: workspaceDomainState, - initial: { initialized: false, workspaceIds: [] }, + initial: { initialized: false, workspaceIds: [], archivedSessionIds: [] }, }, tables: { workspaces: domainTable(workspaceRecord) }, }) diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 4576155f3b..ae4567b27e 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -135,9 +135,16 @@ function record(path: string, sessionIds: string[], createdAt = '2026-07-24T00:0 } } +/** + * Media written before archivedSessionIds existed omit the field; keeping the + * fixtures in that shape continuously proves the schema default upgrades them. + */ +type StoredDomainState = Omit + & Partial> + function storedPool( entries: Array<[string, WorkspaceRecord]>, - state: WorkspaceDomainState, + state: StoredDomainState, ): MemoryMediaPool { const pool = new MemoryMediaPool() pool.versions.set('workspace', DOMAIN_VERSION) @@ -185,7 +192,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { await fiber.await() expect(ctx.workspace.list()).toEqual([]) expect(list).toHaveBeenCalledTimes(1) - expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) }) it('bootstraps once from list headers only, in workspace/session createdAt order', async () => { @@ -218,6 +225,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: result.registry.list().map(workspace => workspace.id), + archivedSessionIds: [], }) }) @@ -246,7 +254,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { const second = await harness({ pool, sessions: [header('late', late, 100)] }) expect(second.list).not.toHaveBeenCalled() expect(second.registry.list()).toEqual([]) - expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) }) it('reuses partial records after a bootstrap record write fails', async () => { @@ -476,7 +484,7 @@ describe('WorkspaceRegistry create and lookup', () => { await expect(result.registry.delete(workspace.id)).resolves.toBe(false) expect(result.registry.get(workspace.id)).toBeUndefined() expect(result.registry.list()).toEqual([]) - expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false) await expect(realpath(dir)).resolves.toBe(dir) expect(result.list).toHaveBeenCalledTimes(1) @@ -519,6 +527,7 @@ describe('WorkspaceRegistry create and lookup', () => { expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], + archivedSessionIds: [], pendingMutation: { operation: 'delete', workspaceId: workspace.id }, }) const reregistered = await first.registry.create(dir) @@ -526,6 +535,7 @@ describe('WorkspaceRegistry create and lookup', () => { expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [reregistered.id], + archivedSessionIds: [], }) await first.fiber.dispose() @@ -762,7 +772,7 @@ describe('header-validated membership projection', () => { const createRecovery = await harness({ pool: interruptedCreate }) expect(createRecovery.registry.list()).toEqual([]) expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false) - expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) const interruptedDelete = storedPool( [[deleteId, record(deleteDir, [])]], @@ -775,7 +785,7 @@ describe('header-validated membership projection', () => { const deleteRecovery = await harness({ pool: interruptedDelete }) expect(deleteRecovery.registry.list()).toEqual([]) expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false) - expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) const corruptPending = storedPool( [[deleteId, record(deleteDir, [])]], @@ -816,3 +826,74 @@ describe('workspace mutation and status', () => { expect(registry.get(workspace.id)).toBe(workspace) }) }) + +describe('registry-global session archive', () => { + it('archives durably in order, idempotently skips repeats, and leaves accounting untouched', async () => { + const dir = await makeDir('archive-home') + const result = await harness({ sessions: [header('kept', dir, 100), header('gone', dir, 200)] }) + const workspace = result.registry.list()[0]! + expect(result.registry.archivedSessionIds).toEqual([]) + + await result.registry.archiveSession(SessionId('gone')) + expect(result.registry.archivedSessionIds).toEqual(['gone']) + // Archiving is a display-set write: the workspace account keeps the id. + expect(workspace.sessionIds).toContain('gone') + expect(storedState(result.pool).archivedSessionIds).toEqual(['gone']) + const changesAfterFirst = result.changes.filter(change => change.table === '').length + + await result.registry.archiveSession(SessionId('gone')) + expect(result.registry.archivedSessionIds).toEqual(['gone']) + // The idempotent repeat neither rewrites the medium nor emits a change. + expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst) + + await result.registry.archiveSession(SessionId('kept')) + expect(result.registry.archivedSessionIds).toEqual(['gone', 'kept']) + }) + + it('accepts unaccounted and live sessions but rejects unknown ids without writing', async () => { + const dir = await makeDir('archive-strays') + const live = await makeDir('archive-live') + const result = await harness({ + sessions: [header('stray', dir, 100)], + liveSessions: [header('live-only', live, 200)], + }) + await result.registry.archiveSession(SessionId('stray')) + await result.registry.archiveSession(SessionId('live-only')) + expect(result.registry.archivedSessionIds).toEqual(['stray', 'live-only']) + + await expect(result.registry.archiveSession(SessionId('ghost'))) + .rejects.toThrow(/cannot archive session 'ghost'/) + expect(storedState(result.pool).archivedSessionIds).toEqual(['stray', 'live-only']) + }) + + it('propagates a persistence-listing failure instead of reporting an unknown session', async () => { + const result = await harness({ sessions: [] }) + result.list.mockRejectedValueOnce(new Error('persistence backend down')) + // The storage fault is the error — never WorkspaceUnknownSessionError, + // which the API layer would misreport as session-not-found. + await expect(result.registry.archiveSession(SessionId('unlisted'))) + .rejects.toThrow(/persistence backend down/) + expect(storedState(result.pool).archivedSessionIds).toEqual([]) + }) + + it('restores the archive set across restarts and defaults it for pre-field media', async () => { + const dir = await makeDir('archive-restart') + const pool = new MemoryMediaPool() + const first = await harness({ pool, sessions: [header('s1', dir, 100)] }) + await first.registry.archiveSession(SessionId('s1')) + await first.fiber.dispose() + + const second = await harness({ pool, sessions: [header('s1', dir, 100)] }) + expect(second.registry.archivedSessionIds).toEqual(['s1']) + await second.fiber.dispose() + + // A medium written before the field existed parses through the schema default. + const legacyId = WorkspaceId('00000000-0000-4000-8000-00000000000a') + const legacy = storedPool( + [[legacyId, record(dir, [])]], + { initialized: true, workspaceIds: [legacyId] }, + ) + const upgraded = await harness({ pool: legacy }) + expect(upgraded.registry.archivedSessionIds).toEqual([]) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c244eaa9cd..96a8df6f8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1413,27 +1413,48 @@ importers: packages/client/ui-permission: devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-schema-form': + specifier: workspace:^ + version: link:../schema-form '@deepseek-ai/dsh-client-ui-command': specifier: workspace:^ version: link:../ui-command + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slash': specifier: workspace:^ version: link:../ui-slash + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-permission': specifier: workspace:^ version: link:../../ui/permission + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-plan: devDependencies: @@ -5434,6 +5455,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval diff --git a/tsconfig.base.json b/tsconfig.base.json index 37216a839e..bf41f4ed94 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -123,11 +123,12 @@ "@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"], "@deepseek-ai/dsh-host-directory-picker": ["./packages/host/directory-picker/src"], "@deepseek-ai/dsh-host-directory-picker/*": ["./packages/host/directory-picker/src/*"], - "@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"], "@deepseek-ai/dsh-host-directory-picker-browse": ["./packages/host/directory-picker-browse/src"], "@deepseek-ai/dsh-host-directory-picker-browse/*": ["./packages/host/directory-picker-browse/src/*"], "@deepseek-ai/dsh-host-directory-picker-native": ["./packages/host/directory-picker-native/src"], "@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"], + "@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"], + "@deepseek-ai/dsh-host-directory-picker-auto/*": ["./packages/host/directory-picker-auto/src/*"], "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"],