merge master and address permission settings review
This commit is contained in:
89 files changed
+1336
-295
No files matched your search
+2
-2
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
+3
-3
@@ -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`,不作兼容承诺),更旧的日志只是没有它,而没有边界的日志会正确地判定没有任何内容属于构造种子历史。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 回放。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
|
||||
2026-07-31-permission-default-for-new-sessions.md: 78ec7a9b7c690c7b29fa10c1518fb7466971f0ac
|
||||
2026-07-31-permission-default-for-new-sessions.zh.md: f6b29b879112de8b8f1c1f7ab466b7d90f9a142c
|
||||
2026-07-31-permission-default-for-new-sessions.md: 35812b53d0c1448afd95b9a063eda6658fb1bef3
|
||||
2026-07-31-permission-default-for-new-sessions.zh.md: a75deaec323b57f2bc88e84dd4d5c7d7d98cd177
|
||||
@@ -12,9 +12,9 @@ The Web General-settings page displayed Permission as a disabled skeleton even t
|
||||
|
||||
`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.
|
||||
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 ownerless General-settings package contributes no placeholder rows.
|
||||
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`.
|
||||
|
||||
|
||||
+2
-2
@@ -12,9 +12,9 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 `
|
||||
|
||||
`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/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`。无归属的「通用」设置包不贡献任何占位行。
|
||||
现有 `/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`。
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<typeof watchConsole>
|
||||
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
|
||||
@@ -238,7 +238,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
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
// 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: 'Danger Full Access' }).waitFor({ timeout: 10_000 })
|
||||
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).
|
||||
@@ -85,7 +85,7 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
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: 'Danger Full Access' })
|
||||
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()
|
||||
@@ -104,6 +104,23 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
['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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
- img
|
||||
- text: 关闭
|
||||
- text: 权限 选择新会话的默认权限模式
|
||||
- button "Danger Full Access":
|
||||
- text: Danger Full Access
|
||||
- button "Full access":
|
||||
- text: Full access
|
||||
- img
|
||||
- text: 语言
|
||||
- button "中文":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -871,7 +871,7 @@ 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: preset table and composition default. */
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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/*`.
|
||||
|
||||
|
||||
@@ -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/*` 的原因。
|
||||
|
||||
|
||||
@@ -831,7 +831,6 @@ flowchart TD
|
||||
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_client_web_react
|
||||
pkg_client_ui_permission --> pkg_invariants
|
||||
pkg_client_ui_permission --> pkg_permission
|
||||
pkg_session_reference --> pkg_agent
|
||||
@@ -1181,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-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), [`client-web-react`](../packages/client/web-react), [`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) |
|
||||
|
||||
@@ -78,7 +78,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
}[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
|
||||
|
||||
@@ -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<string, never>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>): void => {
|
||||
// ArrowLeft/ArrowRight fall through on purpose: the search input keeps
|
||||
@@ -103,55 +104,73 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={css.card}
|
||||
style={{ maxHeight }}
|
||||
aria-label={t('overlay.aria', { command: String(state.command) })}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={css.search}
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.aria')}
|
||||
value={state.search}
|
||||
readOnly={state.submitting}
|
||||
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
|
||||
/>
|
||||
{state.error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
<span className={css.errorText}>{state.error}</span>
|
||||
{state.status === 'failed' && (
|
||||
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>{t('retry')}</button>
|
||||
<>
|
||||
{state.confirming === null && (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={css.card}
|
||||
style={{ maxHeight }}
|
||||
aria-label={t('overlay.aria', { command: String(state.command) })}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className={css.search}
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.aria')}
|
||||
value={state.search}
|
||||
readOnly={state.submitting}
|
||||
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
|
||||
/>
|
||||
{state.error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
<span className={css.errorText}>{state.error}</span>
|
||||
{state.status === 'failed' && (
|
||||
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>{t('retry')}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
|
||||
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
|
||||
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
|
||||
{state.status === 'ready' && (
|
||||
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
|
||||
{rows.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
role="option"
|
||||
aria-selected={index === state.active}
|
||||
className={clsx(css.row, index === state.active && css.rowActive)}
|
||||
// mousedown would race the document capture listener; the shell
|
||||
// owns focus anyway, so a plain click (inside the card → no
|
||||
// dismiss) works.
|
||||
onClick={() => { void popup.select(index) }}
|
||||
onMouseEnter={() => { popup.highlight(index) }}
|
||||
>
|
||||
<span className={css.label}>{option.label}</span>
|
||||
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
|
||||
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
|
||||
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
|
||||
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
|
||||
{state.status === 'ready' && (
|
||||
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
|
||||
{rows.map((option, index) => (
|
||||
<div
|
||||
key={option.id}
|
||||
role="option"
|
||||
aria-selected={index === state.active}
|
||||
className={clsx(css.row, index === state.active && css.rowActive)}
|
||||
// mousedown would race the document capture listener; the shell
|
||||
// owns focus anyway, so a plain click (inside the card → no
|
||||
// dismiss) works.
|
||||
onClick={() => { void popup.select(index) }}
|
||||
onMouseEnter={() => { popup.highlight(index) }}
|
||||
>
|
||||
<span className={css.label}>{option.label}</span>
|
||||
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
|
||||
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{confirmation !== undefined && (
|
||||
<RiskConfirmation
|
||||
open
|
||||
title={confirmation.title}
|
||||
description={confirmation.description}
|
||||
acknowledgeLabel={confirmation.acknowledgeLabel}
|
||||
cancelLabel={confirmation.cancelLabel}
|
||||
confirmLabel={confirmation.confirmLabel}
|
||||
acknowledged={state.acknowledged}
|
||||
onAcknowledgedChange={(value) => { popup.acknowledge(value) }}
|
||||
onCancel={() => { popup.cancelConfirmation() }}
|
||||
onConfirm={() => { void popup.confirm() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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<TCtx = unknown> {
|
||||
*/
|
||||
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<TCtx = unknown> {
|
||||
*/
|
||||
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<TCtx = unknown> {
|
||||
*/
|
||||
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<TCtx = unknown> {
|
||||
async select(index: number): Promise<void> {
|
||||
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<void> {
|
||||
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<TCtx>, option: SelectOption): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<HTMLInputElement>('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<void>((resolve) => { release = resolve }))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: 3e0bca6610e5503e4c2c1f9fe5ad7a07bbcbdd2a
|
||||
README.zh.md: 1606461fc1060825133bb0c3f3f6381bda3926ad
|
||||
@@ -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: <active id>`), 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 <preset>` 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 <preset>` 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)).
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
工具行同样是 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: '<tool>', 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 <preset>` 命令行。
|
||||
审批经由本包声明的链接管编辑器:`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 <preset>`,而 `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 · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ export const zh = {
|
||||
'input.stop': '停止生成',
|
||||
'input.send': '发送消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'access.confirm.title': '确认启用 Full access?',
|
||||
'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
|
||||
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'access.confirm.cancel': '取消',
|
||||
'access.confirm.enable': '启用 Full access',
|
||||
'hero.headline': '开始构建吧',
|
||||
'hero.chooseWorkspace': '选择工作区',
|
||||
'session.hierarchy': '会话层级',
|
||||
@@ -112,6 +117,11 @@ export const en = {
|
||||
'input.stop': 'Stop generating',
|
||||
'input.send': 'Send message',
|
||||
'input.accessMode': 'Access mode, current: {name}',
|
||||
'access.confirm.title': 'Enable Full access?',
|
||||
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
|
||||
'access.confirm.acknowledge': 'I understand the risks and want to continue',
|
||||
'access.confirm.cancel': 'Cancel',
|
||||
'access.confirm.enable': 'Enable Full access',
|
||||
'hero.headline': 'Let\'s start building',
|
||||
'hero.chooseWorkspace': 'Choose workspace',
|
||||
'session.hierarchy': 'Session hierarchy',
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */
|
||||
/* Figma .FileContainerText 1:791: the wrapper uses the shared dock inset
|
||||
inside the composer card around the inset panel. */
|
||||
|
||||
.dock {
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
width: calc(
|
||||
100% -
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
max-width: calc(
|
||||
var(--dsh-composer-card-max-width) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
/* Flex gap still applies after this item; subtract it together with the
|
||||
design's overlap so the later composer paints over the queue edge. */
|
||||
margin: 0 auto calc(
|
||||
|
||||
@@ -73,7 +73,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.dock}>
|
||||
<div className={css.dock} data-queue-dock="">
|
||||
<div className={css.panel}>
|
||||
{queue.length > 1 && (
|
||||
<button
|
||||
|
||||
@@ -133,6 +133,12 @@
|
||||
--dsh-composer-stack-gap: 6px;
|
||||
--dsh-queue-composer-overlap: 5px;
|
||||
|
||||
/* InputBar and dock registrants derive their horizontal geometry from the
|
||||
same card width, outer clearance, and dock inset. */
|
||||
--dsh-composer-card-max-width: 800px;
|
||||
--dsh-composer-side-clearance: 32px;
|
||||
--dsh-composer-dock-inset: 12px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--dsh-composer-stack-gap);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -279,7 +279,7 @@ export function InputBar({
|
||||
// or while the command face is absent with the session).
|
||||
const accessSelect: ReactNode = command === undefined
|
||||
? null
|
||||
: <PermissionSelect value={permissions} locked={locked} command={command} t={t} />
|
||||
: <PermissionSelect key={sessionId} value={permissions} locked={locked} command={command} t={t} />
|
||||
|
||||
// Mirror-layer decorations: a visible backdrop with transparent text. The
|
||||
// claim token highlights through behind the textarea glyphs; each U+FFFC
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmation, setConfirmation] = useState<string | null>(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 (
|
||||
<Menu
|
||||
open={open}
|
||||
items={items}
|
||||
selectedId={currentValue}
|
||||
onSelect={choose}
|
||||
onClose={() => { setOpen(false) }}
|
||||
side="top"
|
||||
anchor={
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={t('input.accessMode', { name: displayName(current?.name ?? currentValue) })}
|
||||
title={current?.description}
|
||||
disabled={locked || busy}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<span className={css.triggerLabel}>{displayName(current?.name ?? currentValue)}</span>
|
||||
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<Menu
|
||||
open={open}
|
||||
items={items}
|
||||
selectedId={currentValue}
|
||||
onSelect={choose}
|
||||
onClose={() => { setOpen(false) }}
|
||||
side="top"
|
||||
anchor={
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current) })}
|
||||
title={current?.description}
|
||||
disabled={locked || busy}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
|
||||
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<RiskConfirmation
|
||||
open={confirmation !== null}
|
||||
title={t('access.confirm.title')}
|
||||
description={t('access.confirm.description')}
|
||||
acknowledgeLabel={t('access.confirm.acknowledge')}
|
||||
cancelLabel={t('access.confirm.cancel')}
|
||||
confirmLabel={t('access.confirm.enable')}
|
||||
acknowledged={acknowledged}
|
||||
disabled={locked}
|
||||
onAcknowledgedChange={setAcknowledged}
|
||||
onCancel={closeConfirmation}
|
||||
onConfirm={confirmFullAccess}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -46,6 +46,7 @@ interface BenchOptions {
|
||||
variant?: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
t?: InputBarProps['t']
|
||||
command?: (line: string) => Promise<boolean>
|
||||
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(<InputBar {...props} sessionId={'s2' as SessionId} />)
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
expect(command).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a registered entry fills its seat and receives the locked owner prop', () => {
|
||||
|
||||
@@ -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: adb56edfc461d982b436159f025dbd65ae14dd83
|
||||
README.zh.md: c5b7c9efeb0c8688be3704c66baaeeef24831301
|
||||
README.md: 742e82d767152073ab963dc74c0565d6e8f8e5c4
|
||||
README.zh.md: e4b39567e4e39d74fd4d527ed2fcfed8d5318a59
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
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. A push invalidation refetches the descriptor. This value applies only when a later session is created; changing it does not switch the current session.
|
||||
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 <preset>` 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 <preset>` 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.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。推送的失效通知会重新获取描述符。这个值仅在后续会话创建时生效;改变它不会切换当前会话。
|
||||
面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值仅在后续会话创建时生效;改变它不会切换当前会话。选择 Full access 时必须先显式确认风险,该行随后才会写入。
|
||||
|
||||
当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-case 预设名渲染为 Title Case 标签(`workspace-write` → `Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
@@ -45,7 +45,6 @@
|
||||
"@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-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-permission": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
|
||||
@@ -5,78 +5,125 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type {
|
||||
PropsLocale, PropsRuntime, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
PermissionSettingsController, PermissionSettingsState,
|
||||
} from './settings-store.ts'
|
||||
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'
|
||||
|
||||
/** Injected controller and hook for the host-backed preference. */
|
||||
/** Registration-side business face for the host-backed preference. */
|
||||
export interface PermissionRowInjected {
|
||||
/** Permission settings controller. */
|
||||
controller: PermissionSettingsController
|
||||
/** Selector hook bound to the controller snapshot. */
|
||||
useSnapshot: SnapshotSelectorHook<PermissionSettingsState>
|
||||
hooks: {
|
||||
/** Permission settings snapshot bound by the renderer as usePermission. */
|
||||
permission: SnapshotStore<PermissionSettingsState>
|
||||
}
|
||||
/** Load the descriptor when the row first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Persist one advertised preset. */
|
||||
select: (preset: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type PermissionRowProps =
|
||||
PropsRuntime<'settings.general.item'> & PropsLocale<'settings.permission'> & PermissionRowInjected
|
||||
PropsRuntime<'settings.general.item'>
|
||||
& PropsLocale<'settings.permission'>
|
||||
& InjectFace<PermissionRowInjected>
|
||||
|
||||
/**
|
||||
* 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({ controller, useSnapshot, t }: PermissionRowProps) {
|
||||
const state = useSnapshot(snapshot => snapshot)
|
||||
export function PermissionRow({ load, select, usePermission, t }: PermissionRowProps) {
|
||||
const state = usePermission(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmation, setConfirmation] = useState<string | null>(null)
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void controller.load()
|
||||
}, [controller])
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.writable && state.status !== 'unavailable') return
|
||||
setOpen(false)
|
||||
setAcknowledged(false)
|
||||
setConfirmation(null)
|
||||
}, [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'
|
||||
const busy = state.status === 'loading' || state.status === 'saving' || confirmation !== null
|
||||
const label = selected?.label
|
||||
?? (busy ? t('loading') : t('unavailable'))
|
||||
const description: string = state.error ?? t('description')
|
||||
|
||||
return (
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('title')}</div>
|
||||
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
|
||||
<>
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('title')}</div>
|
||||
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { 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)
|
||||
setConfirmation(id)
|
||||
return
|
||||
}
|
||||
void select(id)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={busy || !state.writable || state.options.length === 0}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{label}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={state.options.map(option => ({ id: option.id, label: option.label }))}
|
||||
selectedId={state.currentValue}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
void controller.select(id)
|
||||
<RiskConfirmation
|
||||
open={confirmation !== null}
|
||||
title={t('confirm.title')}
|
||||
description={t('confirm.description')}
|
||||
acknowledgeLabel={t('confirm.acknowledge')}
|
||||
cancelLabel={t('confirm.cancel')}
|
||||
confirmLabel={t('confirm.enable')}
|
||||
acknowledged={acknowledged}
|
||||
disabled={!state.writable || state.status === 'saving'}
|
||||
onAcknowledgedChange={setAcknowledged}
|
||||
onCancel={() => {
|
||||
setAcknowledged(false)
|
||||
setConfirmation(null)
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (!acknowledged || confirmation === null) return
|
||||
const preset = confirmation
|
||||
setAcknowledged(false)
|
||||
setConfirmation(null)
|
||||
void select(preset)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={busy || !state.writable || state.options.length === 0}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{label}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
/**
|
||||
* Permission plugin, browser half. The General-settings row writes the
|
||||
* default preset for subsequently created sessions through Settings; the
|
||||
* `/permission` popup decoration switches the current session through the
|
||||
* host command and its `permissions` projection.
|
||||
* Permission preset plugin, browser half — a popupSelect DECORATION hung on
|
||||
* the host `/permission` command: one flat list of presets, current value
|
||||
* marked active, a pick executes the switch. The decoration owns only the
|
||||
* bare invocation; the host command keeps its catalog row, the argued path
|
||||
* (`/permission <preset>` still switches directly), and the lifecycle
|
||||
* logging. Options and the active mark read the session's `permissions`
|
||||
* projection (the same host-computed select the composer chip renders); a
|
||||
* pick submits the `/permission <preset>` command line, so both surfaces
|
||||
* write through one path and the pushed projection frame is the one
|
||||
* confirmation. The Full access row carries the same explicit risk gate as
|
||||
* the composer chip; the shared popup shell owns the modal mechanics.
|
||||
* The General-settings row separately writes the default preset for sessions
|
||||
* created later through the host Settings API.
|
||||
*/
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
// Type-only: pulls the General item slot and locale service contracts.
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
|
||||
import { PermissionRow } from './PermissionRow.tsx'
|
||||
import type { PermissionRowInjected } from './PermissionRow.tsx'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { displayPresetName } from './presentation.ts'
|
||||
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'
|
||||
@@ -29,20 +41,33 @@ export type {
|
||||
/** Required services (cordis fiber inject). */
|
||||
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
|
||||
}
|
||||
|
||||
/** 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: displayPresetName(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,6 +79,30 @@ function optionsOf(value: PermissionSelect): SelectOption[] {
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const command = ctx.get('command') as CommandServiceContract
|
||||
const sessions = ctx.sessions
|
||||
// This optional bundle and ui-conversation can load independently, so each
|
||||
// owns the same safety copy under its own locale namespace.
|
||||
/* jscpd:ignore-start */
|
||||
ctx.effect(() => {
|
||||
const disposers = [
|
||||
ctx.locale.register(ACCESS_NS, 'zh', {
|
||||
'confirm.title': 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
|
||||
|
||||
@@ -61,8 +110,13 @@ export function apply(ctx: ClientContext): void {
|
||||
|
||||
const connection = ctx.get('connection') as ConnectionHandle
|
||||
const controller = new PermissionSettingsController(connection.api)
|
||||
const useSnapshot = bindSnapshotSelector(controller.store)
|
||||
const injected = (): PermissionRowInjected => ({ controller, useSnapshot })
|
||||
const load = (): Promise<void> => controller.load()
|
||||
const select = (preset: string): Promise<void> => controller.select(preset)
|
||||
const injected = (): PermissionRowInjected => ({
|
||||
hooks: { permission: controller.store },
|
||||
load,
|
||||
select,
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const refresh = (ns?: string): void => {
|
||||
@@ -102,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)
|
||||
|
||||
@@ -6,6 +6,11 @@ export const zh = {
|
||||
'description': '选择新会话的默认权限模式',
|
||||
'loading': '加载中',
|
||||
'unavailable': '不可用',
|
||||
'confirm.title': '确认启用 Full access?',
|
||||
'confirm.description': '启用 Full access 后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
|
||||
'confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'confirm.cancel': '取消',
|
||||
'confirm.enable': '启用 Full access',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings.permission namespace key union. */
|
||||
@@ -17,4 +22,30 @@ export const en = {
|
||||
'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<PermissionSettingsKey, string>
|
||||
|
||||
/** 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<string, string>
|
||||
|
||||
/** 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<PermissionAccessKey, string>
|
||||
@@ -1,3 +1,6 @@
|
||||
/** 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.
|
||||
@@ -7,3 +10,13 @@ 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)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import {
|
||||
nodeAtPath, rehydrateSchema, type SchemaNode,
|
||||
} from '@deepseek-ai/dsh-client-schema-form'
|
||||
import { displayPresetName } from './presentation.ts'
|
||||
import { displayPermissionPreset } from './presentation.ts'
|
||||
|
||||
/** Permission's settings namespace on the host wire. */
|
||||
export const PERMISSION_SETTINGS_NS = 'permission'
|
||||
@@ -65,8 +65,8 @@ export function permissionDefaultOf(view: SettingsNamespaceView): {
|
||||
return [{
|
||||
id: choice.value,
|
||||
label: typeof described === 'string' && described.length > 0
|
||||
? displayPresetName(described)
|
||||
: displayPresetName(choice.value),
|
||||
? displayPermissionPreset(choice.value, described)
|
||||
: displayPermissionPreset(choice.value, choice.value),
|
||||
}]
|
||||
})
|
||||
if (options.length === 0 || !options.some(option => option.id === value)) {
|
||||
|
||||
@@ -14,8 +14,11 @@ import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/cl
|
||||
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 } from '../src/client/PermissionRow.tsx'
|
||||
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
|
||||
|
||||
@@ -32,6 +35,7 @@ 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',
|
||||
@@ -96,9 +100,10 @@ describe('ui-permission browser plugin', () => {
|
||||
expect(c.ui.kind).toBe('popupSelect')
|
||||
const row = b.permissionRow()!
|
||||
expect(row.options).toEqual({ id: 'permission', order: -20 })
|
||||
const injected = row.inject?.()
|
||||
expect(injected?.controller).toBeDefined()
|
||||
expect(typeof injected?.useSnapshot).toBe('function')
|
||||
const injected = row.inject?.() as PermissionRowInjected | undefined
|
||||
expect(injected?.hooks.permission).toBeDefined()
|
||||
expect(typeof injected?.load).toBe('function')
|
||||
expect(typeof injected?.select).toBe('function')
|
||||
})
|
||||
|
||||
it('availability follows the projection key; options mark the current value active and exclude custom', async () => {
|
||||
@@ -116,7 +121,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')
|
||||
|
||||
@@ -10,12 +10,13 @@ import { PermissionSettingsController } from '../src/client/settings-store.ts'
|
||||
afterEach(cleanup)
|
||||
|
||||
const SCHEMA = {
|
||||
uid: 4,
|
||||
uid: 5,
|
||||
refs: {
|
||||
1: { type: 'const', value: 'read-only' },
|
||||
2: { type: 'const', value: 'workspace-write' },
|
||||
3: { type: 'union', list: [1, 2] },
|
||||
4: { type: 'object', dict: { defaultPreset: 3 } },
|
||||
3: { type: 'const', value: 'danger-full-access' },
|
||||
4: { type: 'union', list: [1, 2, 3] },
|
||||
5: { type: 'object', dict: { defaultPreset: 4 } },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,8 +47,9 @@ function mount(controller: PermissionSettingsController) {
|
||||
return render(
|
||||
<PermissionRow
|
||||
{...runtime}
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
load={() => controller.load()}
|
||||
select={preset => controller.select(preset)}
|
||||
usePermission={bindSnapshotSelector(controller.store)}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
@@ -78,6 +80,27 @@ describe('PermissionRow', () => {
|
||||
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()
|
||||
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: {
|
||||
|
||||
@@ -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((
|
||||
<div className={css.root} role="presentation">
|
||||
<div className={css.mask} aria-hidden="true" onClick={onClose} />
|
||||
<div
|
||||
@@ -61,7 +65,7 @@ export function Modal({
|
||||
? children
|
||||
: (
|
||||
<>
|
||||
<div className={css.content}>
|
||||
<div className={clsx(css.content, contentClassName)}>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{title}</h2>
|
||||
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
|
||||
@@ -78,5 +82,5 @@ export function Modal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
), document.body)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
.confirmation {
|
||||
width: min(440px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.confirmationContent {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.confirmation {
|
||||
max-height: calc(100dvh - 48px);
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.warning p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.acknowledgement {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acknowledgement input {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 3px 0 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acknowledgement input:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.acknowledgement input:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modalAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.confirmAction {
|
||||
min-width: 136px;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Controlled risk acknowledgement dialog shared by product surfaces that
|
||||
* must gate a sensitive action behind an explicit checkbox.
|
||||
*/
|
||||
import { Button } from './Button.tsx'
|
||||
import { IconWarningOutline16 } from './icons/index.tsx'
|
||||
import { Modal } from './Modal.tsx'
|
||||
import css from './RiskConfirmation.module.css'
|
||||
|
||||
export interface RiskConfirmationProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
acknowledgeLabel: string
|
||||
cancelLabel: string
|
||||
confirmLabel: string
|
||||
acknowledged: boolean
|
||||
disabled?: boolean
|
||||
onAcknowledgedChange: (acknowledged: boolean) => void
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one in-page confirmation whose primary action is unavailable until
|
||||
* the caller-controlled acknowledgement is checked.
|
||||
*/
|
||||
export function RiskConfirmation({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
acknowledgeLabel,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
acknowledged,
|
||||
disabled = false,
|
||||
onAcknowledgedChange,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RiskConfirmationProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
title={title}
|
||||
className={css.confirmation ?? ''}
|
||||
contentClassName={css.confirmationContent ?? ''}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction} onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.confirmAction}
|
||||
disabled={disabled || !acknowledged}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.warning}>
|
||||
<IconWarningOutline16 size={18} className={css.warningIcon} />
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<label className={css.acknowledgement}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
onChange={(event) => { onAcknowledgedChange(event.currentTarget.checked) }}
|
||||
/>
|
||||
<span>{acknowledgeLabel}</span>
|
||||
</label>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
|
||||
export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
|
||||
export { HoverCard } from './HoverCard.tsx'
|
||||
export { Modal } from './Modal.tsx'
|
||||
export { RiskConfirmation } from './RiskConfirmation.tsx'
|
||||
export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
|
||||
export { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||
export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
|
||||
@@ -324,12 +324,17 @@ describe('Modal', () => {
|
||||
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
rerender(
|
||||
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." footer={<button type="button">Create</button>}>
|
||||
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." contentClassName="scrolling-content" footer={<button type="button">Create</button>}>
|
||||
<input aria-label="name" />
|
||||
</Modal>)
|
||||
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create new workspace' })
|
||||
expect(dialog).toBeDefined()
|
||||
// The full-page layer escapes caller stacking contexts but remains in
|
||||
// this document/current WebUI window.
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body)
|
||||
expect(screen.getByRole('button', { name: 'Configure later' })).toBeDefined()
|
||||
expect(screen.getByText('Name it.')).toBeDefined()
|
||||
expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content')
|
||||
fireEvent.keyDown(document, { key: 'a' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
@@ -847,18 +847,7 @@ describe('workspace context request injection', () => {
|
||||
it('mounts without requiring a filesystem provider', async () => {
|
||||
const ctx = new Context()
|
||||
try {
|
||||
const outcome = await Promise.race([
|
||||
ctx.plugin(workspaceContext, { maxBytes: 65536 }).then(() => {
|
||||
return 'settled' as const
|
||||
}),
|
||||
new Promise<'pending'>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve('pending')
|
||||
}, 50)
|
||||
}),
|
||||
])
|
||||
|
||||
expect(outcome).toBe('settled')
|
||||
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
@@ -414,7 +414,9 @@ export class Session {
|
||||
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
|
||||
* boundary: a resumed session's constructor seed is its full stored log,
|
||||
* while its header keeps the original fork value — this field is the
|
||||
* in-process construction fact.
|
||||
* in-process construction fact. An explicitly supplied empty seed has the
|
||||
* same value as no seed (0); its `session/end-seed` event preserves the
|
||||
* lifecycle distinction.
|
||||
*
|
||||
* Not persisted itself: a seeded session projects it into the log as the
|
||||
* `session/end-seed` event, which is what a consumer reading STORED history
|
||||
@@ -430,7 +432,7 @@ export class Session {
|
||||
readonly firstLiveSeq: number
|
||||
|
||||
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
|
||||
if (seed) {
|
||||
if (seed !== undefined) {
|
||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
||||
// live log that no persistence backend could store: each event's `data`
|
||||
@@ -467,7 +469,7 @@ export class Session {
|
||||
// captures the creation seed: no load-time write. Re-marking is skipped
|
||||
// because a cold session is resumed on first touch, so repeatedly opening
|
||||
// one must not grow its log per open.
|
||||
if (this.firstLiveSeq > 0 && this.log.at(-1)?.type !== 'session/end-seed') {
|
||||
if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
|
||||
this.append('session/end-seed', {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +256,9 @@ export interface SessionEventMap {
|
||||
/**
|
||||
* Marks the end of a constructor seed. Events before it have smaller seq
|
||||
* values and came from the seed (resume, fork, or replay); this lifecycle
|
||||
* produced none of them. This log-only event is the durable projection of
|
||||
* produced none of them. An explicitly supplied empty seed puts the marker
|
||||
* at seq 0, distinguishing an empty resumed session from a fresh session.
|
||||
* This log-only event is the durable projection of
|
||||
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
|
||||
* carry the meaning.
|
||||
*
|
||||
|
||||
@@ -112,9 +112,9 @@ describe('Session properties', () => {
|
||||
const original = build(events)
|
||||
const replayed = new Session(SessionId(`replay-${counter++}`), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
// A non-empty replay grows by exactly one log-only boundary.
|
||||
// Every explicit replay grows by exactly one log-only boundary.
|
||||
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
|
||||
expect(replayed.seq).toBe(original.seq === 0 ? 0 : original.seq + 1)
|
||||
expect(replayed.seq).toBe(original.seq + 1)
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -194,6 +194,21 @@ describe('Session', () => {
|
||||
expect(replayed.firstLiveSeq).toBe(original.seq)
|
||||
})
|
||||
|
||||
it('marks an explicitly empty seed without marking a fresh session', () => {
|
||||
const fresh = new Session(SessionId('fresh-empty'))
|
||||
expect(fresh.events).toEqual([])
|
||||
|
||||
const resumed = new Session(SessionId('resumed-empty'), [])
|
||||
expect(resumed.firstLiveSeq).toBe(0)
|
||||
expect(resumed.events).toMatchObject([
|
||||
{ type: 'session/end-seed', seq: 0, data: {} },
|
||||
])
|
||||
|
||||
const reopened = new Session(SessionId('reopened-empty'), resumed.events)
|
||||
expect(reopened.firstLiveSeq).toBe(1)
|
||||
expect(reopened.events).toEqual(resumed.events)
|
||||
})
|
||||
|
||||
it('rejects pre-provider request headers and assistant messages on seed/load', () => {
|
||||
const requestHeader = {
|
||||
type: 'request/header', seq: 0, time: 1,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/permission/README.md
|
||||
README.md: 576555b56c82e4041f2862bcb41ce137cb2d4f77
|
||||
README.zh.md: 894289314a4fb2fc22216462d3f3e0544d7d17bb
|
||||
README.md: 4f7f560bb81eaad3b6b95b2742432fa252682d5a
|
||||
README.zh.md: 79d0ce9c095d3426f3219f04d9cb7ec3b161a184
|
||||
@@ -6,7 +6,7 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s
|
||||
|
||||
`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
|
||||
|
||||
The service owns the `permission` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permission/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed preserves its effective permission and receives only missing durable facts rather than the latest user default.
|
||||
The service owns the `permission` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permission/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
|
||||
|
||||
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
@@ -25,3 +25,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
|
||||
- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
|
||||
- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.
|
||||
- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset.
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
|
||||
|
||||
该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed 会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。
|
||||
该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
|
||||
|
||||
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
|
||||
@@ -25,3 +25,4 @@
|
||||
- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。
|
||||
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。
|
||||
- **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。
|
||||
- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。
|
||||
@@ -177,7 +177,7 @@ export class PermissionService extends Service {
|
||||
defaultPreset: z.string(),
|
||||
})
|
||||
|
||||
static inject = ['bash', 'approval']
|
||||
static inject = ['bash', 'approval', 'sessions']
|
||||
|
||||
private readonly presets: Record<string, PresetSpec>
|
||||
private defaultSettings: () => PermissionSettings
|
||||
@@ -220,6 +220,9 @@ export class PermissionService extends Service {
|
||||
ctx.on('session/created', (session) => {
|
||||
this.pinInitialPermission(session)
|
||||
})
|
||||
for (const session of ctx.sessions.list()) {
|
||||
this.pinInitialPermission(session)
|
||||
}
|
||||
|
||||
// The permissions projection unit: fold the three whole-value knob
|
||||
// events; view derives the select over the composition defaults this
|
||||
|
||||
@@ -31,6 +31,7 @@ async function mounted(options: {
|
||||
approvalDefault?: ApprovalPolicy | undefined
|
||||
} = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.provide('bash', {
|
||||
sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write',
|
||||
resolve() { throw new Error('permission tests do not execute bash') },
|
||||
@@ -228,6 +229,38 @@ describe('new-session default', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves composition defaults when an empty stored session resumes', async () => {
|
||||
const ctx = await mountedStore()
|
||||
await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
|
||||
defaultPreset: 'danger-full-access',
|
||||
})
|
||||
const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] })
|
||||
expect(ctx.permission.current(resumed.events)).toBe('workspace-write')
|
||||
expect(resumed.events.map(event => event.type)).toEqual([
|
||||
'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy',
|
||||
])
|
||||
})
|
||||
|
||||
it('pins sessions that already exist when the service remounts', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.provide('bash', {
|
||||
sandboxMode: 'workspace-write',
|
||||
resolve() { throw new Error('permission tests do not execute bash') },
|
||||
run() { throw new Error('permission tests do not execute bash') },
|
||||
start() { throw new Error('permission tests do not execute bash') },
|
||||
})
|
||||
ctx.provide('approval', { config: { policy: 'ask' } })
|
||||
const existing = ctx.sessions.create(SessionId('existing-before-permission'))
|
||||
expect(existing.events).toEqual([])
|
||||
|
||||
await ctx.plugin(PermissionService, {})
|
||||
expect(existing.events.map(event => event.type)).toEqual([
|
||||
'permission/preset', 'sandbox/mode', 'approval/policy',
|
||||
])
|
||||
expect(ctx.permission.current(existing.events)).toBe('workspace-write')
|
||||
})
|
||||
|
||||
it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => {
|
||||
const ctx = await mountedStore()
|
||||
const partial = freshSession('partial-source')
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
|
||||
@@ -11,6 +22,7 @@ const OWNERSHIP_MARKER_VERSION = 1
|
||||
const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks'
|
||||
const INSTALL_LOCK = 'dsh-lefthook-install.lock'
|
||||
const INSTALL_LOCK_TIMEOUT_MS = 30_000
|
||||
const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 1_000
|
||||
const INSTALL_LOCK_POLL_MS = 50
|
||||
const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
|
||||
const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
|
||||
@@ -303,6 +315,11 @@ function parseInstallLock(record) {
|
||||
return Number.isSafeInteger(owner) ? owner : undefined
|
||||
}
|
||||
|
||||
function installLockRecordMayBeIncomplete(record) {
|
||||
// Exclusive creation exposes the inode before its owner record is fully written.
|
||||
return record === '' || (!record.endsWith('\n') && /^[1-9]\d*(?: [0-9a-f-]*)?$/i.test(record))
|
||||
}
|
||||
|
||||
function lockOwnerIsAlive(owner) {
|
||||
try {
|
||||
process.kill(owner, 0)
|
||||
@@ -351,11 +368,29 @@ async function acquireInstallLock(commonDirectory) {
|
||||
const lockPath = join(commonDirectory, INSTALL_LOCK)
|
||||
const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
|
||||
const ownedRecord = `${String(process.pid)} ${randomUUID()}\n`
|
||||
let initializingLock
|
||||
while (true) {
|
||||
try {
|
||||
writeFileSync(lockPath, ownedRecord, { flag: 'wx', mode: 0o600 })
|
||||
const ownedStat = installLockStat(lockPath)
|
||||
if (ownedStat === undefined || !ownedStat.isFile() || ownedStat.isSymbolicLink()) {
|
||||
const lockHandle = openSync(lockPath, 'wx', 0o600)
|
||||
let ownedStat
|
||||
try {
|
||||
ownedStat = fstatSync(lockHandle)
|
||||
const writeDelay = Number(process.env.DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS ?? 0)
|
||||
if (writeDelay > 0) {
|
||||
await new Promise(resolveWait => setTimeout(resolveWait, writeDelay))
|
||||
}
|
||||
writeFileSync(lockHandle, ownedRecord)
|
||||
} finally {
|
||||
closeSync(lockHandle)
|
||||
}
|
||||
const publishedStat = installLockStat(lockPath)
|
||||
if (
|
||||
publishedStat === undefined
|
||||
|| !publishedStat.isFile()
|
||||
|| publishedStat.isSymbolicLink()
|
||||
|| publishedStat.dev !== ownedStat.dev
|
||||
|| publishedStat.ino !== ownedStat.ino
|
||||
) {
|
||||
throw lockOwnershipChangedError(lockPath)
|
||||
}
|
||||
return () => releaseInstallLock(lockPath, ownedRecord, ownedStat)
|
||||
@@ -368,8 +403,36 @@ async function acquireInstallLock(commonDirectory) {
|
||||
}
|
||||
const existingRecord = readInstallLock(lockPath)
|
||||
if (existingRecord === undefined) continue
|
||||
const verifiedStat = installLockStat(lockPath)
|
||||
if (verifiedStat === undefined) continue
|
||||
if (!verifiedStat.isFile() || verifiedStat.isSymbolicLink()) {
|
||||
throw manualLockRecoveryError(lockPath, 'invalid')
|
||||
}
|
||||
if (verifiedStat.dev !== existingStat.dev || verifiedStat.ino !== existingStat.ino) continue
|
||||
const owner = parseInstallLock(existingRecord)
|
||||
if (owner === undefined) throw manualLockRecoveryError(lockPath, 'invalid')
|
||||
if (owner === undefined) {
|
||||
if (!installLockRecordMayBeIncomplete(existingRecord)) {
|
||||
throw manualLockRecoveryError(lockPath, 'invalid')
|
||||
}
|
||||
const now = Date.now()
|
||||
if (
|
||||
initializingLock === undefined
|
||||
|| initializingLock.dev !== existingStat.dev
|
||||
|| initializingLock.ino !== existingStat.ino
|
||||
) {
|
||||
initializingLock = {
|
||||
deadline: now + INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS,
|
||||
dev: existingStat.dev,
|
||||
ino: existingStat.ino,
|
||||
}
|
||||
}
|
||||
if (now >= initializingLock.deadline) {
|
||||
throw manualLockRecoveryError(lockPath, 'invalid')
|
||||
}
|
||||
await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
|
||||
continue
|
||||
}
|
||||
initializingLock = undefined
|
||||
if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale')
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`)
|
||||
|
||||
@@ -196,7 +196,7 @@ function runInstaller(
|
||||
})
|
||||
}
|
||||
|
||||
describe('worktree-local Lefthook installer', () => {
|
||||
describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
for (const [label, extraEnv] of [
|
||||
['CI', { CI: 'true' }],
|
||||
['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
|
||||
@@ -305,7 +305,23 @@ describe('worktree-local Lefthook installer', () => {
|
||||
expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook)
|
||||
expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false)
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
it('waits for a concurrent installer to finish publishing its lock record', async () => {
|
||||
const fixture = createFixture()
|
||||
const lockPath = installLockPath(fixture)
|
||||
const publishing = runInstaller(fixture, fixture.main, {
|
||||
DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS: '200',
|
||||
})
|
||||
await waitForPath(lockPath)
|
||||
expect(readFileSync(lockPath, 'utf8')).toBe('')
|
||||
|
||||
const waiting = runInstaller(fixture, fixture.linked)
|
||||
const results = await Promise.all([publishing, waiting])
|
||||
|
||||
for (const result of results) expect(result.status, result.stderr).toBe(0)
|
||||
expect(existsSync(lockPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('repairs its owned absolute hook path after the checkout moves', async () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"apps/web/tests/message-actions.e2e.ts",
|
||||
"apps/web/tests/queue-actions.e2e.ts",
|
||||
"apps/web/tests/skill-invocation-policy.e2e.ts",
|
||||
"apps/web/tests/access-confirmation.e2e.ts",
|
||||
"apps/cli/tests/**/*.ts",
|
||||
"examples/*/src/**/*.ts",
|
||||
"examples/*/start.ts",
|
||||
|
||||
@@ -130,6 +130,7 @@ export default defineConfig({
|
||||
'packages/client/ui-primitives/src/markdown/plain-text.ts',
|
||||
'packages/client/ui-question/src/client/QuestionComposer.tsx',
|
||||
'packages/client/ui-primitives/src/Menu.tsx',
|
||||
'packages/client/ui-primitives/src/RiskConfirmation.tsx',
|
||||
'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx',
|
||||
'packages/client/ui-workspace/src/client/WorkspacePicker.tsx',
|
||||
'packages/client/web-react/src/*',
|
||||
|
||||
Reference in New Issue
Block a user