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 d652bd075a..2421df88f6 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: e00c0ed8b5852d416baec73a993102aadded9cd3 -2026-07-30-web-config-plane.zh.md: d5dd5e3c044dc5788367eca55e1cc4110ff81180 +2026-07-30-web-config-plane.md: 0e457b6d712cf9e7e2b005f61b97c580a0f2597e +2026-07-30-web-config-plane.zh.md: 4b2a0bd87040e60bc6602c48482bc936e90011ec 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 e00c0ed8b5..0e457b6d71 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, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog is one caption strip over a row of `id`/`name`/`contextWindow` fields per model rather than a labelled card each; every field keeps the indexed `aria-label` that names it, and the captions are hidden from assistive tech so that name is not announced twice. -**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. Visible profile edits land as `settings.mutate` path operations against the stored redacted section, so a set or unset never rebuilds and drops an unseen secret. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. +**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. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. ## 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 DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. 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. A page address for live routes that never declared configurability remains deferred. +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 DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. 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 d5dd5e3c04..4b2a0bd870 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`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录是一条列名说明行,其下每个模型占一行 `id`/`name`/`contextWindow` 字段,而不是每个模型各一张带标签的卡片;每个字段都保留那个为其命名的带序号 `aria-label`,列名则对辅助技术隐藏,以免该名称被播报两次。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。可见的 profile 编辑以 `settings.mutate` 路径操作落到已存储的脱敏分节上,因此 set 或 unset 都不会重建分节并丢掉不可见的机密。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 ## 曾考虑的替代方案 @@ -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`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。为从未声明可配置性的存活路由提供页面地址仍然暂缓。 +整条闭环以无密钥方式固定在浏览器测试通道(`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 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 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-31-gui-full-access-confirmation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml
new file mode 100644
index 0000000000..99ea335b59
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
+2026-07-31-gui-full-access-confirmation.md: 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
new file mode 100644
index 0000000000..ca89ed23fb
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
@@ -0,0 +1,31 @@
+# Agent Note: GUI Full access risk confirmation
+
+Status: implemented
+
+English | [中文](2026-07-31-gui-full-access-confirmation.zh.md)
+
+## Problem
+
+Switching the web client to `danger-full-access` was a single click on 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
+
+**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.
+- 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 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 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
new file mode 100644
index 0000000000..0f487e41b5
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: GUI Full access 风险确认
+
+Status: implemented
+
+[English](2026-07-31-gui-full-access-confirmation.md) | 中文
+
+## Problem
+
+在 Web 客户端的权限选择器中切换到 `danger-full-access` 只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
+
+## Decision
+
+**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不作任何提交。**
+
+- `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` 座位,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
+- 编辑器 chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale 座位以 `access.confirm.*` 键供给。
+- `/permission` popup(ui-permission 骑在 ui-command 外壳上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
+- 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。
+- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
+
+## Alternatives considered
+
+**原生/操作系统或独立窗口确认。** 已拒:对话框必须留在当前 WebUI 窗口内;第二个窗口可能出现在另一块显示器上,使决策脱离其守护的页面状态。
+
+**每个面的安全文案共享一个 locale namespace。** 已拒:ui-permission bundle 与 ui-conversation 可独立加载,而 Settings 警示说明的是另一种只影响后续会话的生效周期。每个 bundle 各自拥有文案,ui-permission 也将 popup 与 Settings 词典分开,而非跨 bundle 边界 import。
+
+**在 host/权限后端把关。** 设计上即出界:本变更只涉浏览器客户端确认流;后端权限语义、默认值与更安全预设的一键行为均不变。
+
+## Consequences
+
+进入 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/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts
new file mode 100644
index 0000000000..89b383da85
--- /dev/null
+++ b/apps/web/tests/access-confirmation.e2e.ts
@@ -0,0 +1,104 @@
+// Web e2e scenario: every visible permission picker gates Full access behind
+// the same locale-aware, in-page risk confirmation. Zero model calls: the
+// scenario boots the shipped Web composition and exercises the real
+// permission projection, client command path, HTTP RPC, and pushed update.
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+/**
+ * connectFreshWorkspace twin over the product default Chinese locale (the
+ * shared helper's anchors assume the English page every other scenario
+ * boots; this scenario deliberately keeps zh, so the localized picker
+ * copy is the anchor set).
+ */
+async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise {
+  await page.getByRole('button', { name: '选择工作区' }).click()
+  await page.getByRole('menuitem', { name: '新建工作区' }).click()
+  const dialog = page.getByRole('dialog', { name: '新建工作区' })
+  await dialog.waitFor({ timeout: 10_000 })
+  await dialog.getByLabel('新工作区名称').fill(name)
+  await dialog.getByRole('button', { name: '创建工作区' }).click()
+  await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
+    .waitFor({ timeout: 15_000 })
+}
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
+const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const MODE = webSnapshotMode()
+
+describe('web e2e: Full access confirmation', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    // CI uses Playwright's pinned browser. A developer may point this one
+    // scenario at an installed Chromium when the matching browser download
+    // is temporarily unavailable.
+    const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH
+    browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
+    // Keep the product default Chinese locale: the golden pins the actual
+    // registered dictionary rather than a test-local translation callback.
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspaceZh(page)
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('requires acknowledgement before the composer picker can enable Full access', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation'))
+    const access = page.locator('button[aria-label^="访问模式"]').first()
+    await access.waitFor({ timeout: 10_000 })
+
+    // Normalize the starting preset through the real command path. The
+    // shipped web config may already start at Full access.
+    if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
+      await access.click()
+      await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
+      await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+        .toBe('访问模式,当前:Workspace Write')
+    }
+
+    await access.click()
+    await page.getByRole('menuitem', { name: 'Full access' }).click()
+    const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' })
+    await dialog.waitFor({ timeout: 10_000 })
+    const enable = dialog.getByRole('button', { name: '启用 Full access' })
+    expect(await enable.isDisabled()).toBe(true)
+
+    // The modal is in this page's body (not a native/new window) and escapes
+    // the sticky composer's stacking context.
+    expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true)
+    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+
+    await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
+    expect(await enable.isEnabled()).toBe(true)
+    await enable.click()
+    await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+      .toBe('访问模式,当前:Full access')
+    expect(await dialog.count()).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('keeps its snapshot inventory closed', async () => {
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+  })
+})
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index 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 e23a5a9e1e..94e730a5c6 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/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts
index 42e44c14ca..9993f18613 100644
--- a/apps/web/tests/queue-actions.e2e.ts
+++ b/apps/web/tests/queue-actions.e2e.ts
@@ -96,6 +96,26 @@ describe('web e2e: queue row actions', () => {
       { timeout: 10_000 },
     ).toBe(2)
 
+    await page.setViewportSize({ width: 640, height: 1000 })
+    const queueBox = await page.locator('[data-queue-dock]').boundingBox()
+    const composerBox = await page.locator('[data-composer-card]').boundingBox()
+    expect(queueBox).not.toBeNull()
+    expect(composerBox).not.toBeNull()
+    expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x)
+    expect(queueBox!.x + queueBox!.width)
+      .toBeLessThanOrEqual(composerBox!.x + composerBox!.width)
+    const queueLeftInset = queueBox!.x - composerBox!.x
+    const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width
+    const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => {
+      const style = getComputedStyle(element)
+      return {
+        dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')),
+      }
+    })
+    expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1)
+    expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1)
+    await page.setViewportSize({ width: 1680, height: 1000 })
+
     const editRow = page.getByText(EDIT, { exact: true }).locator('..')
     await editRow.getByRole('button', { name: 'Edit queued message' }).click()
     const editor = page.getByRole('textbox', { name: 'Edit queued message' })
diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts
index 2e4882627a..b455ec6b58 100644
--- a/apps/web/tests/seeded-history.e2e.ts
+++ b/apps/web/tests/seeded-history.e2e.ts
@@ -237,7 +237,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // where neither half repeats the other (the dispatched `/` and its
     // argument stay out of the title, and the settlement text never restates
     // the command's own name).
-    await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click()
+    await page.getByRole('button', { name: 'Access mode, current: Full access' }).click()
     await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
     await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 })
     // Scoped to the row itself, so unrelated page text that happens to read
diff --git a/apps/web/tests/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/access-confirmation/ui.expected.md b/apps/web/tests/snapshots/access-confirmation/ui.expected.md
new file mode 100644
index 0000000000..1287e6e565
--- /dev/null
+++ b/apps/web/tests/snapshots/access-confirmation/ui.expected.md
@@ -0,0 +1,10 @@
+- dialog "确认启用 Full access?":
+  - heading "确认启用 Full access?" [level=2]
+  - button "Close":
+    - img
+  - img
+  - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
+  - checkbox "我已了解风险,并愿意继续"
+  - text: 我已了解风险,并愿意继续
+  - button "取消"
+  - button "启用 Full access" [disabled]
diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
index 19845a6f77..5d1979eadf 100644
--- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md
+++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
@@ -37,7 +37,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
index 0d0161867c..3ccbdfb332 100644
--- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
+++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
@@ -52,7 +52,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
index 7b181553b7..039ecc99ea 100644
--- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
+++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
@@ -32,7 +32,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
index b45b0bfc0e..7024719a3a 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
@@ -28,7 +28,7 @@
 - textbox "Describe what you want to build"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
index eb1002fd5d..0a2a96cfc3 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
@@ -28,7 +28,7 @@
 - textbox "Describe what you want to build"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Plan mode on, press to turn off": Plan
 - button "Select model":
   - text: Select model
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
index 9e2499c974..113f81f9eb 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
@@ -24,7 +24,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
index c826bd6584..eb3742eb34 100644
--- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
@@ -21,7 +21,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
index 3de32d7e7d..9dcca575de 100644
--- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
@@ -14,7 +14,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md
index 605a75a0f7..4ed6a10c6f 100644
--- a/apps/web/tests/snapshots/live-interactions/retry.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md
@@ -26,7 +26,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md
index 6f7e8cf865..803b8e3639 100644
--- a/apps/web/tests/snapshots/message-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/message-actions/ui.expected.md
@@ -39,7 +39,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model":
   - text: Select model
   - img
diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md
index e4697dfa4f..07f96cb7ae 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/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md
index 915e1b3e40..a81ed0ecd4 100644
--- a/apps/web/tests/snapshots/plan-review/approved.expected.md
+++ b/apps/web/tests/snapshots/plan-review/approved.expected.md
@@ -37,7 +37,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md
index 49125fbcdd..03c5d84005 100644
--- a/apps/web/tests/snapshots/question-composer/answered.expected.md
+++ b/apps/web/tests/snapshots/question-composer/answered.expected.md
@@ -32,7 +32,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
index c96454ed8f..f09469c98c 100644
--- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
@@ -16,7 +16,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md
index 3e4864227d..cd211013c8 100644
--- a/apps/web/tests/snapshots/queue-actions/editing.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md
@@ -29,7 +29,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md
index 9daac19be5..197e1cd622 100644
--- a/apps/web/tests/snapshots/queue-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md
@@ -22,7 +22,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md
index a2d14e611a..7f79051767 100644
--- a/apps/web/tests/snapshots/seeded-history/ui.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md
@@ -42,7 +42,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model":
   - text: Select model
   - img
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/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md
index d84645270c..ba2adad29f 100644
--- a/apps/web/tests/snapshots/steering/settled.expected.md
+++ b/apps/web/tests/snapshots/steering/settled.expected.md
@@ -33,7 +33,7 @@
 - textbox "Message the agent"
 - button "Commands":
   - img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
 - button "Select model, current DeepSeek-V4-Flash":
   - text: DeepSeek-V4-Flash
   - img
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index f5774e1545..d7e48e4e6b 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -43,7 +43,8 @@
     "tests/cordis-tool-round.e2e.ts",
     "tests/message-actions.e2e.ts",
     "tests/queue-actions.e2e.ts",
-    "tests/skill-invocation-policy.e2e.ts"
+    "tests/skill-invocation-policy.e2e.ts",
+    "tests/access-confirmation.e2e.ts"
   ],
   "references": [
     {
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 3fb1e027ff..f3d855bcd5 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`
 
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 8eed1dab5d..0adc848ea5 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`
 
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/event-producer-consumer.md b/docs/event-producer-consumer.md
index e3d2b8dca6..f7c0d85c55 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) |
@@ -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 225da0b51d..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,9 +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
@@ -1146,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) |
@@ -1174,7 +1180,7 @@ flowchart TD
 | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) |
 | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
+| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-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/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 4b22213c15..45445cc9cf 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.
diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx
index 7fe5edcb86..456cdd4200 100644
--- a/packages/client/ui-command/src/client/PopupSelectView.tsx
+++ b/packages/client/ui-command/src/client/PopupSelectView.tsx
@@ -12,7 +12,7 @@
 import { useEffect, useRef } from 'react'
 import { useSyncExternalStore } from 'react'
 import clsx from 'clsx'
-import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import { filterOptions } from './popup.ts'
 import type { PopupSelectController } from './popup.ts'
@@ -60,23 +60,24 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
   // closes the shell before its own handlers run; that click's target then
   // takes focus naturally, so no focusComposer here.
   useEffect(() => {
-    if (!state.open) return
+    if (!state.open || state.confirming !== null) return
     const onPointerDown = (ev: PointerEvent): void => {
       if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
       popup.dismiss()
     }
     document.addEventListener('pointerdown', onPointerDown, true)
     return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
-  }, [state.open, popup])
+  }, [state.open, state.confirming, popup])
 
   // Focus the search input after it mounts (separate effect so the ref is populated).
   useEffect(() => {
-    if (state.open) searchRef.current?.focus()
-  }, [state.open])
+    if (state.open && state.confirming === null) searchRef.current?.focus()
+  }, [state.open, state.confirming])
 
   if (!state.open) return null
 
   const rows = filterOptions(state.options, state.search)
+  const confirmation = state.confirming?.confirmation
 
   const onKeyDown = (ev: React.KeyboardEvent): void => {
     // ArrowLeft/ArrowRight fall through on purpose: the search input keeps
@@ -103,55 +104,73 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
   }
 
   return (
-    
- { popup.setSearch(ev.currentTarget.value) }} - /> - {state.error !== null && ( -
- {state.error} - {state.status === 'failed' && ( - + <> + {state.confirming === null && ( +
+ { popup.setSearch(ev.currentTarget.value) }} + /> + {state.error !== null && ( +
+ {state.error} + {state.status === 'failed' && ( + + )} +
+ )} + {state.status === 'pending' &&
{t('status.loading')}
} + {state.submitting &&
{t('status.applying')}
} + {state.status === 'ready' && rows.length === 0 &&
{t('status.empty')}
} + {state.status === 'ready' && ( +
+ {rows.map((option, index) => ( +
{ void popup.select(index) }} + onMouseEnter={() => { popup.highlight(index) }} + > + {option.label} + {option.detail !== undefined && {option.detail}} + {option.active === true && } +
+ ))} +
)}
)} - {state.status === 'pending' &&
{t('status.loading')}
} - {state.submitting &&
{t('status.applying')}
} - {state.status === 'ready' && rows.length === 0 &&
{t('status.empty')}
} - {state.status === 'ready' && ( -
- {rows.map((option, index) => ( -
{ void popup.select(index) }} - onMouseEnter={() => { popup.highlight(index) }} - > - {option.label} - {option.detail !== undefined && {option.detail}} - {option.active === true && } -
- ))} -
+ {confirmation !== undefined && ( + { popup.acknowledge(value) }} + onCancel={() => { popup.cancelConfirmation() }} + onConfirm={() => { void popup.confirm() }} + /> )} -
+ ) } diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts index 61ab4de2e2..8498b5def8 100644 --- a/packages/client/ui-command/src/client/contract.ts +++ b/packages/client/ui-command/src/client/contract.ts @@ -6,12 +6,23 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' +/** Copy for an option that must be acknowledged before onSelect can run. */ +export interface SelectConfirmation { + readonly title: string + readonly description: string + readonly acknowledgeLabel: string + readonly cancelLabel: string + readonly confirmLabel: string +} + /** One option row of a popupSelect shell. */ export interface SelectOption { readonly id: string readonly label: string readonly detail?: string readonly active?: boolean + /** Optional in-page risk gate owned by the shared popup shell. */ + readonly confirmation?: SelectConfirmation } /** diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index e010c95227..76f3d8c551 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -24,7 +24,7 @@ export { filterOptions, PopupSelectController } from './popup.ts' export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts' export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx' export type { - CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption, + CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectConfirmation, SelectOption, } from './contract.ts' export type { CommandKey } from './locales.ts' diff --git a/packages/client/ui-command/src/client/popup.ts b/packages/client/ui-command/src/client/popup.ts index c2d30f3213..5e20911820 100644 --- a/packages/client/ui-command/src/client/popup.ts +++ b/packages/client/ui-command/src/client/popup.ts @@ -67,12 +67,17 @@ export interface PopupState { readonly active: number /** A select() settlement is in flight: further select/search/highlight no-op until it settles. */ readonly submitting: boolean + /** Option waiting for explicit risk acknowledgement; null during normal selection. */ + readonly confirming: SelectOption | null + /** Caller-controlled checkbox state for the pending confirmation. */ + readonly acknowledged: boolean /** Surfaced settlement failure (options load or onSelect); null when none. */ readonly error: string | null } const CLOSED: PopupState = { - open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null, + open: false, command: null, status: 'pending', options: [], search: '', active: 0, + submitting: false, confirming: null, acknowledged: false, error: null, } /** @@ -166,7 +171,7 @@ export class PopupSelectController { */ setSearch(search: string): void { const s = this.state.getSnapshot() - if (!s.open || s.submitting || search === s.search) return + if (!s.open || s.submitting || s.confirming !== null || search === s.search) return this.state.set({ ...s, search, active: 0 }) } @@ -177,7 +182,7 @@ export class PopupSelectController { */ move(dir: 1 | -1): void { const s = this.state.getSnapshot() - if (!s.open || s.status !== 'ready' || s.submitting) return + if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return const rows = filterOptions(s.options, s.search) if (rows.length === 0) return const active = (s.active + dir + rows.length) % rows.length @@ -191,7 +196,7 @@ export class PopupSelectController { */ highlight(index: number): void { const s = this.state.getSnapshot() - if (!s.open || s.status !== 'ready' || s.submitting) return + if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return this.state.set({ ...s, active: index }) } @@ -209,10 +214,46 @@ export class PopupSelectController { async select(index: number): Promise { const binding = this.binding const s = this.state.getSnapshot() - if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return + if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return const option = filterOptions(s.options, s.search)[index] if (option === undefined) return - this.state.set({ ...s, submitting: true, error: null }) + if (option.confirmation !== undefined) { + this.state.set({ ...s, confirming: option, acknowledged: false, error: null }) + return + } + await this.settle(binding, option) + } + + /** + * Update the explicit checkbox for the currently pending risk gate. + * @param acknowledged - whether the user has acknowledged the displayed risk. + */ + acknowledge(acknowledged: boolean): void { + const s = this.state.getSnapshot() + if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return + this.state.set({ ...s, acknowledged }) + } + + /** Cancel only the risk gate and return to the still-open option picker. */ + cancelConfirmation(): void { + const s = this.state.getSnapshot() + if (!s.open || s.submitting || s.confirming === null) return + this.state.set({ ...s, confirming: null, acknowledged: false }) + } + + /** Settle the gated option only after the checkbox is acknowledged. */ + async confirm(): Promise { + const binding = this.binding + const s = this.state.getSnapshot() + if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return + await this.settle(binding, s.confirming) + } + + /** Run the business settlement for an already admitted option. */ + private async settle(binding: OpenBinding, option: SelectOption): Promise { + const s = this.state.getSnapshot() + if (this.binding !== binding || !s.open || s.submitting) return + this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null }) try { await binding.spec.onSelect(option, binding.context) } catch (error) { diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx index d8fadaae7a..0bbe01b4a9 100644 --- a/packages/client/ui-command/tests/popup-view.spec.tsx +++ b/packages/client/ui-command/tests/popup-view.spec.tsx @@ -38,6 +38,17 @@ const OPTIONS: SelectOption[] = [ { id: 'light', label: 'Light', active: true }, { id: 'sepia', label: 'Sepia', detail: 'warm' }, ] +const GATED: SelectOption = { + id: 'full', + label: 'Full access', + confirmation: { + title: 'Enable Full access?', + description: 'Sensitive operations.', + acknowledgeLabel: 'I understand the risks', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }, +} const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } @@ -149,6 +160,37 @@ describe('PopupSelectView', () => { expect(view.container.childElementCount).toBe(0) }) + it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => { + const onSelect = vi.fn() + const { popup, consume } = await mountOpen({ + options: () => Promise.resolve([GATED]), + onSelect, + }) + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + expect(screen.queryByLabelText('/theme 选项')).toBeNull() + expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy() + const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement + expect(enable.disabled).toBe(true) + expect(onSelect).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' })) + expect(enable.disabled).toBe(false) + await act(async () => { fireEvent.click(enable) }) + expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A') + expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('canceling a gated option returns to the picker with acknowledgement reset', async () => { + await mountOpen({ options: () => Promise.resolve([GATED]) }) + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByLabelText('/theme 选项')).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + expect(screen.getByRole('checkbox').checked).toBe(false) + }) + it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => { let release!: () => void const onSelect = vi.fn(() => new Promise((resolve) => { release = resolve })) diff --git a/packages/client/ui-command/tests/popup.spec.ts b/packages/client/ui-command/tests/popup.spec.ts index 87a1070a40..68e8084b80 100644 --- a/packages/client/ui-command/tests/popup.spec.ts +++ b/packages/client/ui-command/tests/popup.spec.ts @@ -19,6 +19,17 @@ const OPTIONS: SelectOption[] = [ { id: 'light', label: 'Light', active: true }, { id: 'sepia', label: 'Sepia', detail: 'warm' }, ] +const GATED: SelectOption = { + id: 'full', + label: 'Full access', + confirmation: { + title: 'Enable Full access?', + description: 'Sensitive operations.', + acknowledgeLabel: 'I understand', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }, +} const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } @@ -200,6 +211,38 @@ describe('search / move / highlight over the filtered list', () => { }) describe('select', () => { + it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => { + const onSelect = vi.fn() + const deps = makeDeps() + const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps) + await popup.select(0) + expect(popup.state.getSnapshot()).toMatchObject({ + open: true, confirming: GATED, acknowledged: false, submitting: false, + }) + expect(onSelect).not.toHaveBeenCalled() + await popup.confirm() + expect(onSelect).not.toHaveBeenCalled() + popup.acknowledge(true) + await popup.confirm() + expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A) + expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('cancels a confirmation back to the picker without selecting or consuming', async () => { + const onSelect = vi.fn() + const deps = makeDeps() + const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps) + await popup.select(0) + popup.acknowledge(true) + popup.cancelConfirmation() + expect(popup.state.getSnapshot()).toMatchObject({ + open: true, confirming: null, acknowledged: false, submitting: false, + }) + expect(onSelect).not.toHaveBeenCalled() + expect(deps.consume).not.toHaveBeenCalled() + }) + it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => { const seen: Array<{ option: SelectOption; context: Ctx }> = [] const deps = makeDeps() diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 7847c477fa..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: 2f3e545bfd7d29dbdbb0e23d833c2c19ee685a9d -README.zh.md: 12c043f78a242730a6f1e622df997ec5cbacc8fd +README.md: 7c6e36409efd5f2f9224e85a9cbd3a5e515833c1 +README.zh.md: 7661826153bc44ff47a660fa49a4ffd902d93bdd diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 2f3e545bfd..7c6e36409e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission ` command line through the bar's injected `command` callback. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). @@ -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 12c043f78a..7661826153 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,11 +16,13 @@ 声明 `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 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 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 && (
)} - + ) } + const diff = diffCardModel(material.block) + if (diff !== null) return const web = webCardModel(material.block) // Full source-list allowance here (the panel is the single-call reading // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 71b90bfdfa..523f640c0b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -11,7 +11,7 @@ padding: 0 24px; } -/* Cap matches InputBar card width (800). Glow may paint past the sides. */ +/* Cap matches the InputBar card. Glow may paint past the sides. */ .stack { display: flex; flex-direction: column; @@ -19,7 +19,7 @@ /* figma 75:8208: 12 between title block / workspace / card. */ gap: 12px; width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); overflow: visible; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 14aebe899b..7cb3ea2977 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -23,7 +23,7 @@ /* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by the chat scroller. No top pad: the composer stack's gap owns the space above; error/status strips still carry their own margin. */ - padding: 0 32px 8px; + padding: 0 var(--dsh-composer-side-clearance) 8px; } .hero { @@ -33,7 +33,7 @@ .error, .status { width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); margin-bottom: 6px; padding: 4px 8px; border-radius: 8px; @@ -48,7 +48,7 @@ .notice { width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); margin-bottom: 6px; padding: 4px 8px; border-radius: 8px; @@ -69,6 +69,7 @@ } .card { + box-sizing: border-box; position: relative; /* overlay anchor positioning context */ display: flex; flex-direction: column; @@ -76,7 +77,7 @@ top pad on the card before .InputText. */ gap: 12px; width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); padding-top: 10px; /* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says the input border is one notch weaker than buttons) — exactly the diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 6709c4e434..174a258f60 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -279,7 +279,7 @@ export function InputBar({ // or while the command face is absent with the session). const accessSelect: ReactNode = command === undefined ? null - : + : // Mirror-layer decorations: a visible backdrop with transparent text. The // claim token highlights through behind the textarea glyphs; each U+FFFC diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 34a652470e..16974880dd 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -1,21 +1,28 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' -import { Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerBarProps } from '../contract/slots.ts' import css from './PermissionSelect.module.css' +const FULL_ACCESS = 'danger-full-access' + /** * Display transform: kebab-case machine names render as title-case labels * (`workspace-write` → `Workspace Write`); non-kebab host-configured names - * pass through. Twin of the /permission popup's (client ui-permission) — the - * two permission surfaces must show the same text. + * pass through. Full access intentionally overrides the machine-name + * transform so both permission surfaces use the product label `Full access`; + * the warning body remains locale-aware. */ function displayName(name: string): string { if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') } +function optionLabel(option: PermissionSelectValue['options'][number]): string { + return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name) +} + export interface PermissionSelectProps { value: PermissionSelectValue | undefined locked: boolean @@ -27,49 +34,94 @@ export interface PermissionSelectProps { export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) { const [pick, setPick] = useState(null) const [open, setOpen] = useState(false) + const [confirmation, setConfirmation] = useState(null) + const [acknowledged, setAcknowledged] = useState(false) + + useEffect(() => { + if (!locked && value !== undefined) return + setOpen(false) + setAcknowledged(false) + setConfirmation(null) + }, [locked, value]) if (value === undefined) return null const currentValue = pick ?? value.currentValue const current = value.options.find(option => option.value === currentValue) - const busy = pick !== null + const busy = pick !== null || confirmation !== null const items: MenuEntry[] = value.options .filter(o => o.value !== 'custom') - .map(option => ({ id: option.value, label: displayName(option.name) })) + .map(option => ({ id: option.value, label: optionLabel(option) })) - const choose = (id: string): void => { - setOpen(false) - if (id === value.currentValue) return + const submit = (id: string): void => { setPick(id) void command(`/permission ${id}`) .catch(() => false) .then(() => { setPick(null) }) } + const choose = (id: string): void => { + setOpen(false) + if (id === value.currentValue) return + if (id === FULL_ACCESS) { + setAcknowledged(false) + setConfirmation(id) + return + } + submit(id) + } + + const closeConfirmation = (): void => { + setAcknowledged(false) + setConfirmation(null) + } + + const confirmFullAccess = (): void => { + if (locked || !acknowledged || confirmation === null) return + const id = confirmation + closeConfirmation() + submit(id) + } + return ( - { setOpen(false) }} - side="top" - anchor={ - - } - /> + <> + { setOpen(false) }} + side="top" + anchor={ + + } + /> + + ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 5d26aa4a5e..1e557a0c0a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,13 +1,24 @@ /* Todo strip in the composer context stack (Figma 9:959): tip surface, - 14px radius, status icons + secondary item labels. */ + 14px radius, status icons + secondary item labels. It shares the composer + card geometry and adds the dock inset on both sides. */ .root { box-sizing: border-box; flex: none; overflow: hidden; margin: 0 auto; - width: calc(100% - 88px); - max-width: 752px; + width: calc( + 100% - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); + max-width: calc( + var(--dsh-composer-card-max-width) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; background: var(--dsw-specific-tip); diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css new file mode 100644 index 0000000000..3ecf480adf --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css @@ -0,0 +1,130 @@ +/* File-mutation toolview: same geometry/tokens as ToolRow (figma + {Edit,Write} · path), plus the diff card the row stacks under its summary + line. Mirrors bash-sample.module.css, whose terminal card this replaces with + a diff card. */ + +/* Summary line over the diff card; the summary row keeps its own 24px height, + so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.diff { + margin: 4px 0 4px 22px; +} + +.root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-file-mutation-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-file-mutation-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +.leading { + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +/* File-tool path: same geometry as .summary; hover underline + pointer. */ +.fileLink { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + text-align: left; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.fileLink:hover { + text-decoration: underline; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* The result text for an errored mutation, indented to the card's own column + (the diff card's inset) and in the error tone, since it stands in for the diff + card the failure path does not produce. */ +.failure { + margin: 4px 0 4px 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx new file mode 100644 index 0000000000..323a73e77c --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx @@ -0,0 +1,123 @@ +// File-mutation toolview registrant: third-party posture over the keyed +// toolview hole (ctx.slots.register + ToolRowProps only — never imports the +// chat domain), registered under both `edit` and `write`. Product chrome +// matches ToolRow (figma: {Edit,Write} · {path}). +// +// A write/edit call declares the diff render intent, so this row renders the +// applied change through DiffBlock resident below its summary line — the same +// posture BashRow gives a terminal card. The row has no expand control and is +// not a details-panel target (tool rows stopped being one), so the diff body +// is resident rather than expand-gated, and the card's own copy and expand +// controls are the row's only interactions. CHAT_DIFF_MAX_LINES caps the body +// against the message flow; the details panel keeps the block's full default. +// The summary stays a path link (the file-tool interaction) that opens through +// the host. + +import type { Context } from 'cordis' +import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './file-mutation-row.module.css' + +function leadingFor(state: ToolRowState) { + switch (state) { + case 'error': return + case 'stopped': return + // Running keeps the icon — the row sweep carries the in-flight signal. + default: return + } +} + +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * A settled result's text, flattened from its content blocks, for the arm that + * shows a failure the diff card cannot: write/edit return `undefined` from + * `presentResult` on `result.isError`, so an errored mutation has no diff card, + * and the keyed row is not a details-panel target. Without this the failure — + * an `old_string` that did not match, a permission denial — would read as a bare + * red dot with the model-facing error text nowhere on screen. + * @param block - the frozen call slice. + * @returns the result text, or null for a running call or an empty result. + */ +function errorText(block: ToolRowProps['block']): string | null { + if (!('kind' in block)) return null + const parts: string[] = [] + for (const item of block.content) { + if (item.type === 'text') parts.push(item.text) + } + if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) + const text = parts.join('\n') + return text === '' ? null : text +} + +/** + * File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome, + * with the applied diff resident below it. The summary is a path link (a file + * tool's interaction); the host's `openFile` resolves it against the session + * cwd, so this passes the tool's own path verbatim. The card's copy and expand + * controls are the row's only other actions. + */ +export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) { + const model = toolRowModel(toolName, block, cwd) + const diff = diffCardModel(block) + const status = stateStatus(model.state) + const filePath = model.filePath + // An errored mutation has no diff card (presentResult returns undefined on + // isError); surface its result text so the failure is more than a red dot. + const failure = diff === null && model.state === 'error' ? errorText(block) : null + return ( +
+
+ {leadingFor(model.state)} + {status !== null && {status}} + {model.title} + + {filePath !== undefined ? ( + + ) : ( + {model.summary} + )} +
+ {diff !== null && ( + + )} + {failure !== null &&
{failure}
} +
+ ) +} + +/** + * The file-mutation rows as a plain registrant plugin. `inject` carries the + * load-order seam: requiring the conversation service guarantees the chat entry + * (and with it the 'conversation.chat.toolview' declaration) is registered — + * ui-conversation's apply mounts the service after the chat entry. + */ +export const fileMutationToolview = { + name: 'file-mutation-toolview', + inject: ['slots', 'conversation'], + /** + * Register the file-mutation row into the chat view's keyed toolview hole + * under both mutation tool names. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit' }, FileMutationRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write' }, FileMutationRow) + }, +} diff --git a/packages/client/ui-conversation/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/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx new file mode 100644 index 0000000000..10af914bed --- /dev/null +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -0,0 +1,344 @@ +// @vitest-environment jsdom +// The diff render intent on the web side: the pure diffCardModel derivation +// over callView/resultView, and both conversation render sites that consume it +// — the chat tool row's expanded body (GenericToolCard / FileMutationRow) and +// the details panel's Output section. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { FileMutationRow, fileMutationToolview } from '../src/client/toolviews/file-mutation-row.tsx' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const SID = 's1' as SessionId + +const t = makeTranslate(zh, commonZh) + +const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}' + +/** The edit tool's own call view (a call-time diff derived from the arguments). */ +const callDiff = (over?: Partial>): ToolCallView => ({ + card: 'diff', title: 'Edit notes/demo.txt', + diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over, +}) + +/** The edit tool's own result view (the applied hunk diff). */ +const resultDiff = (over?: Partial>): ToolResultView => ({ + card: 'diff', title: 'Edit notes/demo.txt', + diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over, +}) + +const running = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'edit', argsRaw: ARGS, + turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over, +}) + +const settled = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'edit', argsRaw: ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false, + callView: callDiff(), resultView: resultDiff(), ...over, +}) + +describe('diffCardModel', () => { + it('derives a running card from the call view alone', () => { + expect(diffCardModel(running())).toEqual({ + card: { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] }, + }) + }) + + it('derives a settled card from the result view, which replaces the call-time diff', () => { + // The applied hunks (result) win over the args-derived call diff. + expect(diffCardModel(settled({ + resultView: resultDiff({ diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }), + }))).toEqual({ + card: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }, + }) + }) + + it('renders a settled diff even when the window dropped the call head', () => { + // A truncated call carries only the result view, which holds the whole change. + expect(diffCardModel(settled({ call: null, callView: null }))?.card.diffs).toHaveLength(1) + }) + + it('returns null for every non-diff call: no views, generic views, unknown cards', () => { + expect(diffCardModel(running({ callView: null }))).toBeNull() + expect(diffCardModel(settled({ callView: null, resultView: null }))).toBeNull() + expect(diffCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull() + // A generic result settles a diff call on the generic path (write/edit's + // own execution-error arm). + expect(diffCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView + expect(diffCardModel(running({ callView: future }))).toBeNull() + expect(diffCardModel(settled({ + callView: future, resultView: { card: 'chart' } as unknown as ToolResultView, + }))).toBeNull() + }) + + it('falls back to null for a malformed diff payload off the wire', () => { + // toolEventViewSchema validates only the `card` string, so a version + // mismatch can deliver a diff card with an unusable diffs field. Each shape + // routes to the generic path instead of throwing inside DiffBlock. + const bad = (diffs: unknown): ToolResultView => ({ card: 'diff', diffs } as unknown as ToolResultView) + expect(diffCardModel(settled({ resultView: bad(undefined) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad('nope') }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([null]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 1, oldText: null, newText: 'x' }]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: 5, newText: 'x' }]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: null, newText: 9 }]) }))).toBeNull() + // The running side narrows identically. + expect(diffCardModel(running({ callView: { card: 'diff', diffs: 'nope' } as unknown as ToolCallView }))).toBeNull() + }) +}) + +describe('chat row diff body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ + callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), t, + }) + + it('the expanded body is the applied diff, capped tighter than the panel', () => { + expect(CHAT_DIFF_MAX_LINES).toBeLessThan(16) + const view = render() + // Collapsed: the summary row (path) only, no diff body. + expect(view.queryByText('hello fixture')).toBeNull() + // The path link is not the expand control; the leading toggle is. + fireEvent.click(view.container.querySelector('[data-expandable]')!) + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.getByText('hello fixture')).toBeTruthy() + }) + + it('a running diff call expands to its intended change', () => { + const view = render() + fireEvent.click(view.container.querySelector('[data-expandable]')!) + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + }) + + it('a non-diff call keeps the args-JSON text body', () => { + // A non-file tool name so the row is not single-file (no path link), and its + // args body is the fallback the diff card must not have replaced. + const view = render() + fireEvent.click(view.container.querySelector('[data-expandable]')!) + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.getByText(/"foo"/)).toBeTruthy() + }) +}) + +describe('FileMutationRow diff card', () => { + const list = () => createSnapshotStore({ + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + current: SID, + phase: 'ready', + }) + + const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): ToolRowProps => ({ + callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app', + sessionId: SID, useSessions: bindSnapshotSelector(list()), + } as unknown as ToolRowProps) + + it('renders the applied diff under the summary row, without an expand gesture', () => { + const view = render() + // The diff card is resident (no expand toggle needed). + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.getByText('hello fixture')).toBeTruthy() + expect(view.getByText('复制')).toBeTruthy() + }) + + it('the summary is a path link that opens the tool path through the host', () => { + const openFile = vi.fn() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' })) + // The row passes the tool's own path; the injected openFile resolves it + // against the session cwd (apply.ts), so the row must not resolve twice. + expect(openFile).toHaveBeenCalledWith('notes/demo.txt') + }) + + it('registers under write too, rendering a create as an added-only diff', () => { + const writeArgs = '{"file_path":"notes/new.txt","content":"hello fixture\\n"}' + const view = render() + expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy() + }) + + it('reflects the run state on its leading slot', () => { + const runningView = render() + expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() + cleanup() + const errorView = render() + expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() + }) + + it('a mutation call with no diff view renders the summary row alone', () => { + const view = render() + expect(view.container.querySelector('[data-diff]')).toBeNull() + }) + + it('surfaces the result text when an errored mutation has no diff card', () => { + // write/edit return undefined from presentResult on isError, so the failure + // has no diff — the row shows the model-facing error text instead of a bare + // red dot. + const view = render() + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.getByText('old_string not found in notes/demo.txt')).toBeTruthy() + }) + + it('falls back to the error name/code when an errored result has no text block', () => { + const view = render() + expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy() + }) + + it('shows no failure text for a successful diff or a running call', () => { + const ok = render() + expect(ok.container.querySelector('[class*="_failure_"]')).toBeNull() + cleanup() + const run = render() + expect(run.container.querySelector('[class*="_failure_"]')).toBeNull() + }) + + it('shows the stopped state when the call was interrupted', () => { + const view = render() + expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() + // The visually-hidden status label carries the stopped semantic for AT. + expect(view.getByText('已停止')).toBeTruthy() + }) + + it('renders a plain summary span when the call carries no file path', () => { + // Empty args leave deriveFilePath undefined, so the summary is not a link. + const view = render() + expect(view.container.querySelector('[class*="_fileLink_"]')).toBeNull() + expect(view.container.querySelector('[class*="_summary_"]')).not.toBeNull() + }) +}) + +describe('fileMutationToolview registration', () => { + it('registers one component under both edit and write, and each disposes', () => { + const registered: { key: string; disposed: boolean }[] = [] + const disposers: (() => void)[] = [] + const ctx = { + slots: { + register: ({ key }: { name: string; key: string }) => { + const entry = { key, disposed: false } + registered.push(entry) + const dispose = () => { entry.disposed = true } + disposers.push(dispose) + return dispose + }, + }, + } + fileMutationToolview.apply(ctx as never) + expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write']) + // The registrant's inject seam is the load-order contract the row relies on. + expect(fileMutationToolview.inject).toEqual(['slots', 'conversation']) + // Disposal removes each contribution (packages/AGENTS.md registry contract). + for (const dispose of disposers) dispose() + expect(registered.every(r => r.disposed)).toBe(true) + }) +}) + +describe('DetailsPanel diff Output section', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore(cwd === undefined + ? { ids: [], byId: {}, current: undefined, phase: 'ready' } + : { + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + current: SID, + phase: 'ready', + }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + t={t} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], 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/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 8753b5ed66..6831b1a5a0 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -46,6 +46,7 @@ interface BenchOptions { variant?: 'hero' | 'composer' placeholder?: string t?: InputBarProps['t'] + command?: (line: string) => Promise accessory?: React.ReactNode overlay?: React.ReactNode leftItems?: React.ReactNode @@ -108,7 +109,7 @@ function bench(over?: BenchOptions) { useLexicon: bindSnapshotSelector(shell.lexicon), useMenuLauncher: bindSnapshotSelector(menuLauncher), stop, - command: () => Promise.resolve(true), + command: over?.command ?? (() => Promise.resolve(true)), // Mirrors the real lookup chain (conversation namespace, then common). t: over?.t ?? makeTranslate(zh, commonZh), renderSlot, @@ -467,7 +468,35 @@ describe('command launcher chrome and control seats', () => { expect(launcher.getAttribute('aria-expanded')).toBe('true') }) - it('the Access chip renders the projection value and submits /permission on pick', async () => { + it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'read-only', name: 'read-only' }, + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'read-only', + } + const { view } = bench({ permissions, command }) + const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement + // Title-case display is presentation only; the menu ids stay machine names. + expect(trigger.textContent).toBe('Read Only') + fireEvent.click(trigger) + const items = view.getAllByRole('menuitem') + expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access']) + fireEvent.click(items[1]!) + // Optimistic pick + disable until admission resolves (command stub resolves true). + const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement + expect(busy.textContent).toBe('Workspace Write') + expect(busy.disabled).toBe(true) + expect(command).toHaveBeenCalledWith('/permission workspace-write') + await act(async () => {}) + expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false) + }) + + it('requires explicit risk acknowledgement before submitting Full access', async () => { + const command = vi.fn(() => Promise.resolve(true)) const permissions = { options: [ { value: 'workspace-write', name: 'workspace-write' }, @@ -475,20 +504,86 @@ describe('command launcher chrome and control seats', () => { ], currentValue: 'workspace-write', } - const { view } = bench({ permissions }) - const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement - // Title-case display is presentation only; the menu ids stay machine names. - expect(trigger.textContent).toBe('Workspace Write') - fireEvent.click(trigger) - const items = view.getAllByRole('menuitem') - expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access']) - fireEvent.click(items[1]!) - // Optimistic pick + disable until admission resolves (command stub resolves true). - const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement - expect(busy.textContent).toBe('Danger Full Access') - expect(busy.disabled).toBe(true) + const { view } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^访问模式/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + + expect(command).not.toHaveBeenCalled() + expect(view.getByRole('dialog', { name: '确认启用 Full access?' })).toBeTruthy() + const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement + expect(enable.disabled).toBe(true) + + fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' })) + expect(enable.disabled).toBe(false) + fireEvent.click(enable) + + expect(command).toHaveBeenCalledOnce() + expect(command).toHaveBeenCalledWith('/permission danger-full-access') + expect(view.queryByRole('dialog')).toBeNull() + expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access') await act(async () => {}) - expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false) + }) + + it('cancels a Full access selection without changing permission and resets acknowledgement', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view } = bench({ permissions, command }) + const openConfirmation = () => { + fireEvent.click(view.getByLabelText(/^访问模式/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + } + + openConfirmation() + fireEvent.click(view.getByRole('checkbox')) + fireEvent.click(view.getByRole('button', { name: '取消' })) + expect(command).not.toHaveBeenCalled() + expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write') + + openConfirmation() + expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false) + expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true) + }) + + it('revokes an open Full access confirmation when the task locks', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view, session } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^访问模式/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + fireEvent.click(view.getByRole('checkbox')) + act(() => { session.set(snapshotOf({ removed: true })) }) + expect(view.queryByRole('dialog')).toBeNull() + expect(command).not.toHaveBeenCalled() + }) + + it('resets an open Full access confirmation when switching tasks', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view, props } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^访问模式/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + fireEvent.click(view.getByRole('checkbox')) + view.rerender() + expect(view.queryByRole('dialog')).toBeNull() + expect(command).not.toHaveBeenCalled() }) it('a registered entry fills its seat and receives the locked owner prop', () => { diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 2cf8870b64..8f7f098dfc 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: bbd1ad70afd925b2c0b28f444cf8118e241a7723 -README.zh.md: 99ae1e432e261def378a5fac242d24d36a870905 +README.md: 97dd6cca4ba1715dc75211929c47fc6f3e632d78 +README.zh.md: d142e9a94c86a4df3f49ad2ee210e24c267f92e0 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index bbd1ad70af..97dd6cca4b 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 `baseURL` for both families, `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits. A provider 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), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while 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 99ae1e432e..d142e9a94c 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` 或 pi-ai 的 `reasoning`,以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留。只有当某个提供方行仅由用户层承载时它才可删除(删除会还原组合 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),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 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 798f45126c..eb000d4d99 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -13,6 +13,7 @@ flex-direction: column; gap: 12px; max-width: 720px; + color: var(--dsw-alias-label-primary); } .title { @@ -70,39 +71,11 @@ color: var(--dsw-alias-label-primary); } -.badges { - display: inline-flex; - gap: 6px; - flex: 1; -} - -.badgeOk { - display: inline-flex; - align-items: center; - gap: 5px; - color: var(--dsw-alias-state-success-primary); - font-size: 12px; - line-height: 18px; -} - -.badgeOk::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 999px; - background: currentcolor; -} - -.badgeMuted { - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; -} - .rowActions { display: inline-flex; align-items: center; gap: 4px; + margin-left: auto; } /* `box-sizing` on every control here: the app has no global border-box reset, @@ -521,6 +494,19 @@ select.input { 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); +} + /* Icon-button label seat: named for assistive tech and for the tests that query these controls by their text. */ .hiddenLabel { diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 4dea825a2d..8074e0cd6b 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -4,14 +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 { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconPlusOutline16, 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' @@ -115,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) @@ -122,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 */ @@ -175,11 +198,6 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
  • {row.entry.displayName} - - {row.entry.active - ? {t('active')} - : {t('dormant')}} - @@ -278,6 +292,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 c41846216b..51b6821138 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…', @@ -59,12 +62,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 bbfe4782da..db4b3bef22 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' @@ -178,10 +178,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() }) @@ -606,10 +605,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', @@ -617,6 +634,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 @@ -724,6 +763,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 cee54f104c..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,8 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-command" ], @@ -35,20 +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 30fc6d2dd5..d6cd286959 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -8,41 +8,66 @@ * projection (the same host-computed select the composer chip renders); a * pick submits the `/permission ` command line, so both surfaces * write through one path and the pushed projection frame is the one - * confirmation. + * confirmation. The Full access row carries the same explicit risk gate as + * the composer chip; the shared popup shell owns the modal mechanics. + * 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' +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'] +export const inject = ['command', 'sessions', 'slots', 'locale', 'connection'] + +const ACCESS_NS = 'permission.access' /** Read one session's current permissions projection value (undefined = capability absent). */ function selectOf(session: SessionFace | undefined): PermissionSelect | undefined { 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`) so both permission surfaces show - * the same text; non-kebab host-configured names pass through. - */ -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): SelectOption[] { +function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectOption[] { return value.options .filter(option => option.value !== 'custom') .map(option => ({ id: option.value, - label: displayName(option.name), + label: displayPermissionPreset(option.value, option.name), ...(option.description !== undefined ? { detail: option.description } : {}), ...(option.value === value.currentValue ? { active: true } : {}), + ...(option.value === FULL_ACCESS_PRESET + ? { + confirmation: { + title: t('confirm.title'), + description: t('confirm.description'), + acknowledgeLabel: t('confirm.acknowledge'), + cancelLabel: t('confirm.cancel'), + confirmLabel: t('confirm.enable'), + }, + } + : {}), })) } @@ -54,8 +79,72 @@ function optionsOf(value: PermissionSelect): SelectOption[] { export function apply(ctx: ClientContext): void { const command = ctx.get('command') as CommandServiceContract const sessions = ctx.sessions + // This optional bundle and ui-conversation can load independently, so each + // owns the same safety copy under its own locale namespace. + /* jscpd:ignore-start */ + ctx.effect(() => { + const disposers = [ + ctx.locale.register(ACCESS_NS, 'zh', { + 'confirm.title': 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': 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() } + }, 'ui-permission: Full access confirmation dictionaries') + /* jscpd:ignore-end */ + const t = ctx.locale.bind(ACCESS_NS) const sessionFor = (session: ClientSessionContext): SessionFace | undefined => sessions.binding(session.sessionId)?.session + + ctx.effect(() => 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 @@ -67,7 +156,7 @@ export function apply(ctx: ClientContext): void { options: (session) => { const value = selectOf(sessionFor(session)) if (value === undefined) throw new Error('permission presets are not available on this host') - return Promise.resolve(optionsOf(value)) + return Promise.resolve(optionsOf(value, t)) }, onSelect: async (option, session) => { const live = sessionFor(session) diff --git a/packages/client/ui-permission/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 5f9125db53..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) { @@ -60,6 +87,8 @@ async function bench() { 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), } } @@ -69,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 () => { @@ -86,7 +123,14 @@ describe('ui-permission browser plugin', () => { expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true) expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.') // Kebab-case names title-case; non-kebab host-configured names pass through. - expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Danger Full Access']) + expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access']) + expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({ + title: 'Enable Full access?', + description: accessEn['confirm.description'], + acknowledgeLabel: 'I understand the risks and want to continue', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }) b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] }) const passthrough = await c.ui.options(proj, new AbortController().signal) expect(passthrough[0]?.label).toBe('Ask Every Time') @@ -114,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/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 4e8b7d99d8..778fb82387 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,9 +1,11 @@ // Modal: controlled full-viewport dialog (create-workspace and similar). -// Fixed overlay in the React tree (no react-dom portal) so ui-primitives -// stays free of a react-dom dependency; mask tokens match figma 451:18655. +// The overlay portals to this document's body so ancestor stacking contexts +// cannot leave sticky page controls above the mask. This is still an in-page +// WebUI dialog; it never creates or targets another browser/native window. import { useEffect } from 'react' import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutline16 } from './icons/index.tsx' import css from './Modal.module.css' @@ -17,6 +19,7 @@ import css from './Modal.module.css' * @param props.description - optional supporting sentence under the title. * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). + * @param props.contentClassName - optional class for a scrollable content region. * @param props.headless - render children directly in the card (no default * header/close/body chrome) for dialogs whose figma frame owns its own * header structure; mask, card, Escape, and aria-label remain. @@ -25,7 +28,7 @@ import css from './Modal.module.css' * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel = 'Close', description, children, footer, className, headless = false, + open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false, }: { open: boolean onClose: () => void @@ -35,6 +38,7 @@ export function Modal({ children?: ReactNode footer?: ReactNode className?: string + contentClassName?: string headless?: boolean }) { useEffect(() => { @@ -48,7 +52,7 @@ export function Modal({ if (!open) return null - return ( + return createPortal((