From ee74b5b07a56d6032a674c755e0beb858520a62d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 12:48:19 +0800 Subject: [PATCH 1/5] feat(permission): add new-session default setting --- ...mission-default-for-new-sessions.i18n.yaml | 6 + ...-31-permission-default-for-new-sessions.md | 35 +++ ...-permission-default-for-new-sessions.zh.md | 35 +++ apps/web/tests/settings-chrome.e2e.ts | 44 ++- .../settings-chrome/dialog.expected.md | 6 +- docs/config-catalog.md | 9 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 6 +- docs/module-graph.md | 11 +- docs/persistence-catalog.md | 2 +- .../client/ui-permission/README.i18n.yaml | 4 +- packages/client/ui-permission/README.md | 8 +- packages/client/ui-permission/README.zh.md | 8 +- packages/client/ui-permission/package.json | 25 +- .../src/client/PermissionRow.module.css | 60 +++++ .../src/client/PermissionRow.tsx | 88 ++++++ .../client/ui-permission/src/client/index.ts | 81 ++++-- .../ui-permission/src/client/locales.ts | 20 ++ .../ui-permission/src/client/presentation.ts | 9 + .../src/client/settings-store.ts | 191 +++++++++++++ .../client/ui-permission/src/css-modules.d.ts | 4 + packages/client/ui-permission/src/index.ts | 8 +- .../client/ui-permission/src/invariant.ts | 6 +- .../tests/browser-plugin.spec.ts | 38 ++- .../tests/permission-row.spec.tsx | 127 +++++++++ .../tests/settings-store.spec.ts | 254 ++++++++++++++++++ packages/client/ui-permission/tsconfig.json | 18 ++ .../ui-settings-general/README.i18n.yaml | 6 +- packages/client/ui-settings-general/README.md | 4 +- .../client/ui-settings-general/README.zh.md | 4 +- .../client/ui-settings-general/package.json | 2 +- .../src/client/GeneralSection.module.css | 48 ---- .../src/client/GeneralSection.tsx | 70 +++-- .../ui-settings-general/src/client/index.ts | 20 +- .../ui-settings-general/src/client/locales.ts | 12 +- .../ui-settings-general/tests/apply.spec.ts | 14 +- .../tests/components.spec.tsx | 32 +-- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 38 +-- .../apiproxy/tests/api-proxy-config.spec.ts | 40 ++- packages/ui/permission/README.i18n.yaml | 4 +- packages/ui/permission/README.md | 4 +- packages/ui/permission/README.zh.md | 4 +- packages/ui/permission/package.json | 2 + packages/ui/permission/src/index.ts | 94 ++++++- .../ui/permission/tests/permission.spec.ts | 128 ++++++++- .../ui/permission/tests/projection.spec.ts | 9 +- packages/ui/permission/tsconfig.json | 3 + pnpm-lock.yaml | 27 ++ 51 files changed, 1452 insertions(+), 226 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md create mode 100644 packages/client/ui-permission/src/client/PermissionRow.module.css create mode 100644 packages/client/ui-permission/src/client/PermissionRow.tsx create mode 100644 packages/client/ui-permission/src/client/locales.ts create mode 100644 packages/client/ui-permission/src/client/presentation.ts create mode 100644 packages/client/ui-permission/src/client/settings-store.ts create mode 100644 packages/client/ui-permission/src/css-modules.d.ts create mode 100644 packages/client/ui-permission/tests/permission-row.spec.tsx create mode 100644 packages/client/ui-permission/tests/settings-store.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml new file mode 100644 index 0000000000..a76d46c1ce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +2026-07-31-permission-default-for-new-sessions.md: 236e0eedd2b3a6ba64a837fa1838d63545f13fb1 +2026-07-31-permission-default-for-new-sessions.zh.md: 8cdb5e6a0b6ca8a9a878351474728b325fb92528 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md new file mode 100644 index 0000000000..236e0eedd2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -0,0 +1,35 @@ +# Agent Note: Permission Settings default for new sessions + +Status: implemented + +English | [中文](2026-07-31-permission-default-for-new-sessions.zh.md) + +## Problem + +The Web General-settings page displayed Permission as a disabled skeleton even though `dsh-permission` already owned the preset table and current-session switch path. The Settings seam could persist a plugin-owned value, but the Web settings API exposed only configurable LLM-provider namespaces. More importantly, treating a user preference as a live global permission would make an existing session's execution policy change outside its durable log. + +## Decision + +`dsh-permission` owns a `permission` Settings namespace with one `defaultPreset` field. Its base value is `Config.defaultPreset`, or the preset matching the composed sandbox and approval defaults when the config omits it. The schema derives its enum from the configured preset table, so Settings validates stored values and the Web client discovers the deployment's actual choices without duplicating them. + +The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. + +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 retains only the Tool Call skeleton. + +ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes emit `host/settings-changed` but not `host/models-changed`. + +## Consequences + +Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter the open session. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly. + +The assembled Web snapshot now contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `danger-full-access` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet. + +## Alternatives considered + +**Apply the Settings value live to every session.** Rejected because execution policy would change without a session event and replay could not reconstruct which permission governed an earlier tool call. + +**Record only `permission/preset` on creation.** Rejected because sandbox and approval are independently owned whole-value knobs; pinning all three facts keeps their consumers independent of future composition-default changes. + +**Expose all Settings registrations, or add a generic `local-client` declaration.** Rejected for this change because it expands a security boundary and the Settings contract beyond the one requested preference. The explicit `permission` allowlist entry is sufficient and leaves future namespaces to make their own exposure decision. + +**Apply the latest default while resuming a seeded session.** Rejected because resume must preserve the session's prior effective execution policy; missing legacy facts are materialized from that policy instead. diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md new file mode 100644 index 0000000000..8cdb5e6a0b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 新会话的权限 Settings 默认值 + +Status: implemented + +[English](2026-07-31-permission-default-for-new-sessions.md) | 中文 + +## 问题 + +Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 `dsh-permission` 已经拥有 preset 表和当前会话的切换路径。Settings seam 可以持久化由插件拥有的值,但 Web Settings API 只暴露可配置 LLM 提供方的 namespace。更重要的是,如果把用户偏好当成实时生效的全局权限,现有会话的执行策略就会在其持久日志之外发生变化。 + +## 决策 + +`dsh-permission` 拥有一个 `permission` Settings namespace,其中只有 `defaultPreset` 字段。它的基础值是 `Config.defaultPreset`;省略该配置时,则使用与组合后的沙箱和审批默认值匹配的 preset。schema 的 enum 从已配置的 preset 表派生,因此 Settings 既能校验已存储的值,Web 客户端也能发现部署中的实际选项,而无需重复定义。 + +服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。 + +现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。无归属的「通用」设置包只保留「工具调用」骨架。 + +ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更会发出 `host/settings-changed`,但不会发出 `host/models-changed`。 + +## 后果 + +在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已打开的会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`。 + +组装后的 Web 快照现在包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `danger-full-access` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。 + +## 曾考虑的替代方案 + +**将 Settings 值实时应用于每个会话。** 不予采纳,因为执行策略会在没有会话事件的情况下改变,重放也无法重建先前工具调用采用了哪种权限。 + +**创建时只记录 `permission/preset`。** 不予采纳,因为沙箱和审批是由不同组件独立拥有的全量值调节项;固定全部三个事实,可以让其消费方不依赖未来的组合默认值变化。 + +**暴露所有 Settings 注册,或增加通用的 `local-client` 声明。** 本次变更不予采纳,因为这会扩大安全边界,并使 Settings 契约超出所请求的单项偏好。显式加入 `permission` allowlist 已足够,未来的 namespace 可以各自决定是否暴露。 + +**恢复带 seed 的会话时应用最新默认值。** 不予采纳,因为恢复操作必须保留会话先前的有效执行策略;缺失的旧版事实应从该策略中补齐。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 775f1596df..e81b8ab6fd 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -2,15 +2,18 @@ // section switching, both close paths), the Appearance preference row (the // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme // -> theme/change -> ui-layout's presenter -> body attribute -> alias token) -// and the Language row (settings-scoped localization + persisted dsh.locale). +// and the Language row (settings-scoped localization + persisted dsh.locale), +// plus Permission as the persisted default for subsequently created sessions. // Zero model calls: everything is pure client + persistence state on a blank // frame, so there is no fixture and a stray stream would fail loud on the // open llm seam. +import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { join } from 'node:path' +import { SessionId } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, @@ -21,7 +24,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md') const MODE = webSnapshotMode() -describe('web e2e: settings modal, appearance gesture, language switch', () => { +describe('web e2e: settings modal and General preferences', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -50,9 +53,10 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => { const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) expect(await trigger.getAttribute('aria-expanded')).toBe('true') - // General is the active section by default; its skeleton rows plus the - // functional Language and Appearance rows render. + // General is active by default; Permission, Language and Appearance are + // functional, while Tool Call remains a skeleton. expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true') + await dialog.getByRole('button', { name: 'Danger Full Access' }).waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1) // Golden of the freshly opened dialog (default zh, General active). @@ -73,6 +77,38 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('stores Permission as the default for future sessions without changing an existing session', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission')) + const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before')) + expect(existing.events.find(event => event.type === 'permission/preset')?.data) + .toEqual({ preset: 'danger-full-access' }) + + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.waitFor({ timeout: 10_000 }) + const selector = dialog.getByRole('button', { name: 'Danger Full Access' }) + await selector.waitFor({ timeout: 10_000 }) + expect(await selector.isEnabled()).toBe(true) + await selector.click() + await page.getByRole('menuitem', { name: 'Read Only' }).click() + await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 }) + + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('permission:') + expect(document).toContain('defaultPreset: read-only') + expect(existing.events.find(event => event.type === 'permission/preset')?.data) + .toEqual({ preset: 'danger-full-access' }) + + const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after')) + expect(created.events.map(event => [event.type, event.data])).toEqual([ + ['permission/preset', { preset: 'read-only' }], + ['sandbox/mode', { mode: 'read-only' }], + ['approval/policy', { policy: 'ask' }], + ]) + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('flips the theme through the Appearance cubes and persists across reload', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> => diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index 75959994f1..ff65e630f0 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -10,9 +10,9 @@ - button "关闭": - img - text: 关闭 - - text: 权限 选择默认权限模式 - - button "Read only" [disabled]: - - text: Read only + - text: 权限 选择新会话的默认权限模式 + - button "Danger Full Access": + - text: Danger Full Access - img - text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言 - button "中文": diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3fb1e027ff..6fcd606e54 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -874,7 +874,7 @@ Source: [`packages/mcp/mcp-client/src/index.ts:93`](../packages/mcp/mcp-client/s Requires: `bash` · `approval` ```ts config-catalog -/** The {@link PermissionService} config: the deployment's preset table. */ +/** The {@link PermissionService} config: preset table and composition default. */ export interface Config { /** * The preset table: name → knob bundle. Defaults to `workspace-write` @@ -882,6 +882,11 @@ export interface Config { * never). The name `custom` is reserved for the derived not-a-preset state. */ presets?: Record + /** + * Default for new sessions. When omitted, the preset matching the composed + * sandbox and approval defaults is used. + */ + defaultPreset?: string } /** One preset's sandbox/approval bundle and optional client presentation. */ @@ -899,7 +904,7 @@ export interface PresetSpec { Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMode`](core-data-structures/sandbox.md) -Source: [`packages/ui/permission/src/index.ts:130`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:140`](../packages/ui/permission/src/index.ts) ## `@deepseek-ai/dsh-plan-mode` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8eed1dab5d..c6b435fefb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -934,7 +934,7 @@ set(session: Session, name: string): void Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/ui/permission/src/index.ts:144`](../../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:159`](../../packages/ui/permission/src/index.ts) ## `ctx.planMode` — `PlanModeService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e3d2b8dca6..f7c0d85c55 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -34,7 +34,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:59`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | @@ -66,14 +66,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | -| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models` | +| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | | `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | | `models/changed` | `runtime` (`emit`) | `ui-models` | -| `settings/changed` | `runtime` (`emit`) | `ui-models` | +| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission` | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 225da0b51d..a459e37a89 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -652,6 +652,7 @@ flowchart TD pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session pkg_permission --> pkg_session_projection + pkg_permission --> pkg_settings pkg_permission --> pkg_user_approval pkg_client_ui_goal --> pkg_client_connection pkg_client_ui_goal --> pkg_client_locale @@ -822,9 +823,15 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction + pkg_client_ui_permission --> pkg_client_connection + pkg_client_ui_permission --> pkg_client_locale pkg_client_ui_permission --> pkg_client_runtime + pkg_client_ui_permission --> pkg_client_schema_form pkg_client_ui_permission --> pkg_client_ui_command + pkg_client_ui_permission --> pkg_client_ui_primitives pkg_client_ui_permission --> pkg_client_ui_slash + pkg_client_ui_permission --> pkg_client_ui_slots + pkg_client_ui_permission --> pkg_client_web_react pkg_client_ui_permission --> pkg_invariants pkg_client_ui_permission --> pkg_permission pkg_session_reference --> pkg_agent @@ -1146,7 +1153,7 @@ flowchart TD | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | +| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1174,7 +1181,7 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 4d4faa42e3..b91588ef5a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -350,7 +350,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src 'permission/preset': { preset: string } ``` -Source: [`packages/ui/permission/src/index.ts:49`](../packages/ui/permission/src/index.ts) +Source: [`packages/ui/permission/src/index.ts:50`](../packages/ui/permission/src/index.ts) ### `plan/*` diff --git a/packages/client/ui-permission/README.i18n.yaml b/packages/client/ui-permission/README.i18n.yaml index 12fef93f39..f72b04feda 100644 --- a/packages/client/ui-permission/README.i18n.yaml +++ b/packages/client/ui-permission/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-permission/README.md -README.md: 3377a1c5907b67b065879b012923427685c106d6 -README.zh.md: 34cf6f72394632968ded1671a5ac0377e5c78cc6 +README.md: adb56edfc461d982b436159f025dbd65ae14dd83 +README.zh.md: c5b7c9efeb0c8688be3704c66baaeeef24831301 diff --git a/packages/client/ui-permission/README.md b/packages/client/ui-permission/README.md index 3377a1c590..adb56edfc4 100644 --- a/packages/client/ui-permission/README.md +++ b/packages/client/ui-permission/README.md @@ -2,13 +2,15 @@ English | [中文](README.zh.md) -Permission preset selection plugin, browser half: a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write` → `Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows no picker (a decoration never manufactures a catalog row). +Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. A push invalidation refetches the descriptor. This value applies only when a later session is created; changing it does not switch the current session. + +The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write` → `Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row. The `/client` export surface is the plugin body (`apply`/`inject`). ## Model Experience -Indirectly, through the host `/permission` command the picker submits: a switch appends the whole-value knob events (`permission/preset`, `sandbox/mode`, `approval/policy`), which select the sandbox mode and approval policy later tool calls resolve. Picker interaction adds no prompt content. +Indirectly, through the permission facts written by its two surfaces: the Settings row causes a future session to start with whole-value knob events (`permission/preset`, `sandbox/mode`, `approval/policy`), while the `/permission` picker appends the same facts when it switches the current session; those events select the sandbox mode and approval policy later tool calls resolve, and picker interaction adds no prompt content. #### KV Cache effect @@ -16,4 +18,4 @@ No direct invalidation; the knob consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **No keyless snapshot exercises the picker yet** — the popup flow is covered by unit specs over fake faces; the assembled-transcript scenario rides the deferred approval/preset e2e work. +- **The Settings row is Web-only** — non-Web clients may still switch the current session through `/permission`, but do not receive this browser contribution. diff --git a/packages/client/ui-permission/README.zh.md b/packages/client/ui-permission/README.zh.md index 34cf6f7239..c5b7c9efeb 100644 --- a/packages/client/ui-permission/README.zh.md +++ b/packages/client/ui-permission/README.zh.md @@ -2,13 +2,15 @@ [English](README.md) | 中文 -权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-case 预设名渲染为 Title Case 标签(`workspace-write` → `Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合不显示选择框(装饰绝不无中生有目录行)。 +面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。推送的失效通知会重新获取描述符。这个值仅在后续会话创建时生效;改变它不会切换当前会话。 + +当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-case 预设名渲染为 Title Case 标签(`workspace-write` → `Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。 `/client` 导出面为插件本体(`apply`/`inject`)。 ## Model Experience -间接影响,经由选择框提交的 host `/permission` 命令:一次切换追加全量值旋钮事件(`permission/preset`、`sandbox/mode`、`approval/policy`),决定后续工具调用解析到的沙箱模式与审批策略。选择框交互本身不添加任何提示词内容。 +通过两个界面写入的权限事实间接影响:Settings 行使未来会话带着全量值旋钮事件(`permission/preset`、`sandbox/mode`、`approval/policy`)启动,而 `/permission` 选择框切换当前会话时会追加相同的事实;这些事件决定后续工具调用解析到的沙箱模式与审批策略,选择框交互本身不添加任何提示词内容。 #### KV Cache effect @@ -16,4 +18,4 @@ ## Known Limitations and Deferred Work -- **尚无无密钥快照覆盖选择框** —— popup 流程由基于 fake face 的单元 spec 覆盖;组装态转写场景随延后的审批/预设 e2e 工作一并补齐。 +- **Settings 行仅在 Web 中可用**:非 Web 客户端仍可通过 `/permission` 切换当前会话,但不会获得这项浏览器贡献。 diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index cee54f104c..037627b06b 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-permission", - "description": "Permission preset selection: the /permission popupSelect over the permissions projection and the host /permission command", + "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", "version": "0.0.1", "private": true, "type": "module", @@ -24,8 +24,10 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-command" + "@deepseek-ai/dsh-client-ui-command", + "@deepseek-ai/dsh-client-locale" ], "platform": "web" }, @@ -35,20 +37,35 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-schema-form": "^0.0.1", "@deepseek-ai/dsh-client-ui-command": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-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" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-permission/src/client/PermissionRow.module.css b/packages/client/ui-permission/src/client/PermissionRow.module.css new file mode 100644 index 0000000000..b7a11bed6a --- /dev/null +++ b/packages/client/ui-permission/src/client/PermissionRow.module.css @@ -0,0 +1,60 @@ +/* Permission row: title/description plus the preset selector pill. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.selector:disabled { + cursor: default; +} + +.chevron { + flex: none; +} diff --git a/packages/client/ui-permission/src/client/PermissionRow.tsx b/packages/client/ui-permission/src/client/PermissionRow.tsx new file mode 100644 index 0000000000..390cbffd70 --- /dev/null +++ b/packages/client/ui-permission/src/client/PermissionRow.tsx @@ -0,0 +1,88 @@ +/** + * Permission preference row: the default preset for subsequently created + * sessions. Current-session switches remain on the composer `/permission` + * control. + */ + +import { useEffect, useState } from 'react' +import type { + 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 { PermissionSettingsKey } from './locales.ts' +import css from './PermissionRow.module.css' + +/** Injected controller and hook for the host-backed preference. */ +export interface PermissionRowInjected { + /** Permission settings controller. */ + controller: PermissionSettingsController + /** Selector hook bound to the controller snapshot. */ + useSnapshot: SnapshotSelectorHook +} + +/** Full component props. */ +export type PermissionRowProps = + PropsRuntime<'settings.general.item'> & PropsLocale<'settings.permission'> & 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) + const [open, setOpen] = useState(false) + useEffect(() => { + void controller.load() + }, [controller]) + 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 label = selected?.label + ?? (busy ? t('loading') : t('unavailable')) + const description: string = state.error ?? t('description') + + return ( +
+
+
{t('title')}
+
{description}
+
+ { setOpen(false) }} + items={state.options.map(option => ({ id: option.id, label: option.label }))} + selectedId={state.currentValue} + onSelect={(id) => { + setOpen(false) + void controller.select(id) + }} + align="end" + portal + anchor={( + + )} + /> +
+ ) +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Permission row copy. */ + 'settings.permission': PermissionSettingsKey + } +} diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index 30fc6d2dd5..65d66c4104 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -1,46 +1,46 @@ /** - * 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 ` 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 ` command line, so both surfaces - * write through one path and the pushed projection frame is the one - * confirmation. + * 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. */ 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 { PermissionRow } from './PermissionRow.tsx' +import type { PermissionRowInjected } from './PermissionRow.tsx' +import { en, zh } from './locales.ts' +import { displayPresetName } from './presentation.ts' +import { + PERMISSION_SETTINGS_NS, PermissionSettingsController, refreshPermissionIfLoaded, +} from './settings-store.ts' + +export type { PermissionRowInjected, PermissionRowProps } from './PermissionRow.tsx' +export type { + PermissionDefaultOption, PermissionSettingsState, +} from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['command', 'sessions'] +export const inject = ['command', 'sessions', 'slots', 'locale', 'connection'] /** Read one session's current permissions projection value (undefined = capability absent). */ function selectOf(session: SessionFace | undefined): PermissionSelect | undefined { return session?.projections.faceOf('permissions').getSnapshot() as PermissionSelect | undefined } -/** - * Display transform twin of the composer chip's (ui-conversation - * PermissionSelect): kebab-case machine names render as title-case labels - * (`workspace-write` → `Workspace Write`) so both permission surfaces show - * the same text; non-kebab host-configured names pass through. - */ -function displayName(name: string): string { - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name - return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') -} - /** Flatten the projection select into popup rows; `custom` is display state, never a target. */ function optionsOf(value: PermissionSelect): SelectOption[] { return value.options .filter(option => option.value !== 'custom') .map(option => ({ id: option.value, - label: displayName(option.name), + label: displayPresetName(option.name), ...(option.description !== undefined ? { detail: option.description } : {}), ...(option.value === value.currentValue ? { active: true } : {}), })) @@ -56,6 +56,41 @@ export function apply(ctx: ClientContext): void { const sessions = ctx.sessions const sessionFor = (session: ClientSessionContext): SessionFace | undefined => sessions.binding(session.sessionId)?.session + + ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries') + + const connection = ctx.get('connection') as ConnectionHandle + const controller = new PermissionSettingsController(connection.api) + const useSnapshot = bindSnapshotSelector(controller.store) + const injected = (): PermissionRowInjected => ({ controller, useSnapshot }) + + ctx.effect(() => { + const refresh = (ns?: string): void => { + if (ns !== undefined && ns !== PERMISSION_SETTINGS_NS) return + refreshPermissionIfLoaded(controller) + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + return () => { + controller.dispose() + for (const dispose of disposers) dispose() + } + }, 'ui-permission: settings invalidations') + + ctx.effect(() => { + const row = deferRegistration(ctx.slots, 'settings.general.item', PermissionRow, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'permission', + order: -20, + locale: 'settings.permission', + inject: injected, + }, PermissionRow)) + return () => { row.dispose() } + }, 'ui-permission: General settings row') + ctx.effect(() => command.decorate({ name: 'permission', // The picker exists exactly while the projection does: a permission-less diff --git a/packages/client/ui-permission/src/client/locales.ts b/packages/client/ui-permission/src/client/locales.ts new file mode 100644 index 0000000000..748235c1ee --- /dev/null +++ b/packages/client/ui-permission/src/client/locales.ts @@ -0,0 +1,20 @@ +/** `settings.permission` namespace dictionaries (the Permission row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'title': '权限', + 'description': '选择新会话的默认权限模式', + 'loading': '加载中', + 'unavailable': '不可用', +} satisfies Record + +/** The settings.permission namespace key union. */ +export type PermissionSettingsKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'title': 'Permission', + 'description': 'Choose the default permission mode for new sessions', + 'loading': 'Loading', + 'unavailable': 'Unavailable', +} satisfies Record diff --git a/packages/client/ui-permission/src/client/presentation.ts b/packages/client/ui-permission/src/client/presentation.ts new file mode 100644 index 0000000000..752daedf11 --- /dev/null +++ b/packages/client/ui-permission/src/client/presentation.ts @@ -0,0 +1,9 @@ +/** + * Convert conventional kebab-case preset names into user-facing title case. + * @param name - host-supplied preset label or key. + * @returns the title-cased conventional key, or a non-kebab label unchanged. + */ +export function displayPresetName(name: string): string { + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name + return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') +} diff --git a/packages/client/ui-permission/src/client/settings-store.ts b/packages/client/ui-permission/src/client/settings-store.ts new file mode 100644 index 0000000000..830347aef7 --- /dev/null +++ b/packages/client/ui-permission/src/client/settings-store.ts @@ -0,0 +1,191 @@ +/** + * Permission default-settings controller. The host descriptor supplies the + * current value and the dynamic preset enum; writes target only + * `defaultPreset` and carry the descriptor revision. + */ + +import type { + IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { + createSnapshotStore, type SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + nodeAtPath, rehydrateSchema, type SchemaNode, +} from '@deepseek-ai/dsh-client-schema-form' +import { displayPresetName } from './presentation.ts' + +/** Permission's settings namespace on the host wire. */ +export const PERMISSION_SETTINGS_NS = 'permission' + +/** One selectable new-session default. */ +export interface PermissionDefaultOption { + /** Preset key written to Settings. */ + id: string + /** Host-supplied label or a title-cased preset key. */ + label: string +} + +/** Permission settings-row snapshot. */ +export interface PermissionSettingsState { + status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error' + error: string | null + writable: boolean + currentValue: string + options: readonly PermissionDefaultOption[] + revision: number +} + +interface ConstChoice { + type: string + value?: unknown + meta?: { description?: unknown } +} + +/** + * Read the dynamic preset enum encoded by the host's `defaultPreset` schema. + * @param view - permission namespace descriptor. + * @returns current value and selectable options. + */ +export function permissionDefaultOf(view: SettingsNamespaceView): { + currentValue: string + options: PermissionDefaultOption[] +} { + const value = (view.value as { defaultPreset?: unknown } | null)?.defaultPreset + if (typeof value !== 'string') throw new Error('permission settings has no defaultPreset value') + const node = nodeAtPath(rehydrateSchema(view.schema), ['defaultPreset']) + if (node === undefined) throw new Error('permission settings schema has no defaultPreset field') + const rawChoices = node.type === 'union' + ? (node.list as SchemaNode[] | undefined) ?? [] + : [node] + const options = rawChoices.flatMap((candidate) => { + const choice = candidate as unknown as ConstChoice + if (choice.type !== 'const' || typeof choice.value !== 'string') return [] + const described = choice.meta?.description + return [{ + id: choice.value, + label: typeof described === 'string' && described.length > 0 + ? displayPresetName(described) + : displayPresetName(choice.value), + }] + }) + if (options.length === 0 || !options.some(option => option.id === value)) { + throw new Error('permission settings schema does not advertise its current preset') + } + return { currentValue: value, options } +} + +/** Controller joining Settings reads, writes, and pushed invalidations. */ +export class PermissionSettingsController { + /** Row snapshot consumed through a bound selector hook. */ + readonly store: SnapshotStore = createSnapshotStore({ + status: 'idle', + error: null, + writable: false, + currentValue: '', + options: [], + revision: 0, + }) + + private generation = 0 + private view: SettingsNamespaceView | undefined + + /** @param api - Settings wire face. */ + constructor(private readonly api: Pick) {} + + /** + * Refresh the permission descriptor. Latest request wins. + * @returns nothing; {@link store} carries success or failure. + */ + async load(): Promise { + const generation = ++this.generation + this.store.update((state) => { + state.status = 'loading' + state.error = null + }) + try { + const response = await this.api.settings.describe({}) + if (!response.result.ok) throw new Error(response.result.error.message) + if (generation !== this.generation) return + const view = response.result.value.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS) + if (view === undefined) { + this.view = undefined + this.store.update((state) => { + state.status = 'unavailable' + state.writable = false + state.currentValue = '' + state.options = [] + }) + return + } + this.accept(view, response.result.value.writable) + } catch (error) { + if (generation !== this.generation) return + this.fail(error) + } + } + + /** + * Persist one preset as the default for subsequently created sessions. + * @param preset - advertised preset key. + * @returns nothing; {@link store} carries success or failure. + */ + async select(preset: string): Promise { + const view = this.view + const state = this.store.getSnapshot() + if (view === undefined || !state.writable) return + const generation = ++this.generation + this.store.update((draft) => { + draft.status = 'saving' + draft.error = null + }) + try { + const response = await this.api.settings.mutate({ + ns: PERMISSION_SETTINGS_NS, + ops: [{ op: 'set', path: ['defaultPreset'], value: preset }], + expectedRevision: view.revision, + }) + if (generation !== this.generation) return + if (!response.result.ok) throw new Error(response.result.error.message) + this.accept(response.result.value, true) + } catch (error) { + if (generation !== this.generation) return + this.fail(error) + } + } + + /** Stop in-flight responses from publishing after plugin disposal. */ + dispose(): void { + this.generation += 1 + this.view = undefined + } + + private accept(view: SettingsNamespaceView, writable: boolean): void { + const resolved = permissionDefaultOf(view) + this.view = view + this.store.update((state) => { + state.status = 'ready' + state.error = null + state.writable = writable + state.currentValue = resolved.currentValue + state.options = resolved.options + state.revision = view.revision + }) + } + + private fail(error: unknown): void { + this.store.update((state) => { + state.status = 'error' + state.error = error instanceof Error ? error.message : String(error) + }) + } +} + +/** + * Refetch only after the row has opened once. + * @param controller - permission settings controller. + */ +export function refreshPermissionIfLoaded(controller: PermissionSettingsController): void { + if (controller.store.getSnapshot().status === 'idle') return + void controller.load() +} diff --git a/packages/client/ui-permission/src/css-modules.d.ts b/packages/client/ui-permission/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-permission/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-permission/src/index.ts b/packages/client/ui-permission/src/index.ts index 5359562972..5c28cd69b2 100644 --- a/packages/client/ui-permission/src/index.ts +++ b/packages/client/ui-permission/src/index.ts @@ -1,8 +1,8 @@ /** - * Permission preset selection plugin, node half. Pure UI plugin: the empty - * apply exists so the plugin appears in the host cordis.yml / Loader; the - * browser half ships via exports["./client"], discovered through the - * package.json dshClient declaration. + * Permission surfaces plugin, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half ships the + * new-session Settings row and current-session command picker through + * exports["./client"], discovered from the package.json dshClient declaration. */ /** Host plugin body — no host-side behavior for this surface plugin. */ diff --git a/packages/client/ui-permission/src/invariant.ts b/packages/client/ui-permission/src/invariant.ts index c0fd33a80b..1c3f7d6500 100644 --- a/packages/client/ui-permission/src/invariant.ts +++ b/packages/client/ui-permission/src/invariant.ts @@ -15,9 +15,9 @@ export const name = 'client-ui-permission-invariant' export const inject = ['invariants'] /** - * No runtime invariant: a single command contribution registration whose disposal is - * proven by the HMR-safety spec — it emits no cordis events and owns no - * cross-plugin mutable state. + * No runtime invariant: the command and slot contribution lifecycles are + * proven by the HMR-safety spec, while the browser-only Settings controller + * owns no host events or cross-plugin mutable state. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 5f9125db53..cbdb30a5fd 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -5,13 +5,16 @@ * the current value active and `custom` excluded; availability follows the * projection key's presence; a pick submits the /permission line through * Session.command and surfaces rejection/unmatched as thrown errors; fiber - * disposal removes the contribution (HMR safety). + * disposal removes the contribution (HMR safety). The same plugin registers + * its Settings row and invalidates that row on host settings changes. */ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' +import { PermissionRow } from '../src/client/PermissionRow.tsx' import { apply, inject } from '../src/client/index.ts' const sid = (k: string): SessionId => k as SessionId @@ -27,6 +30,26 @@ const SELECT: PermissionSelect = { async function bench() { const ctx = new Context() + await ctx.plugin(SlotsService) + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + ctx.slots.register({ + name: 'root', + children: { + 'settings.general.item': { kind: 'list', scope: 'root' }, + }, + } as never, () => null) + ctx.provide('connection', { + api: { + settings: { + describe: () => Promise.resolve({ + rpcId: 'describe', + result: { ok: true as const, value: { writable: true, namespaces: [] } }, + }), + mutate: () => Promise.reject(new Error('settings mutation is not exercised')), + }, + }, + } as never) let decoration: CommandDecoration | undefined ctx.provide('command', { decorate(c: CommandDecoration) { @@ -60,6 +83,8 @@ async function bench() { ctx, fiber, values, commands, setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r }, decoration: () => decoration, + permissionRow: () => ctx.slots.entries('settings.general.item') + .find(entry => entry.component === PermissionRow), } } @@ -69,6 +94,11 @@ describe('ui-permission browser plugin', () => { const c = b.decoration()! expect(c.name).toBe('permission') expect(c.ui.kind).toBe('popupSelect') + const row = b.permissionRow()! + expect(row.options).toEqual({ id: 'permission', order: -20 }) + const injected = row.inject?.() + expect(injected?.controller).toBeDefined() + expect(typeof injected?.useSnapshot).toBe('function') }) it('availability follows the projection key; options mark the current value active and exclude custom', async () => { @@ -114,7 +144,11 @@ describe('ui-permission browser plugin', () => { it('disposal removes the decoration (HMR safety)', async () => { const b = await bench() expect(b.decoration()).toBeDefined() + b.ctx.emit('settings/changed', 'another') + b.ctx.emit('settings/changed', 'permission') + b.ctx.emit('connection/reset') await b.fiber.dispose() expect(b.decoration()).toBeUndefined() + expect(b.permissionRow()).toBeUndefined() }) }) diff --git a/packages/client/ui-permission/tests/permission-row.spec.tsx b/packages/client/ui-permission/tests/permission-row.spec.tsx new file mode 100644 index 0000000000..685df69749 --- /dev/null +++ b/packages/client/ui-permission/tests/permission-row.spec.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' +import { en } from '../src/client/locales.ts' +import { PermissionSettingsController } from '../src/client/settings-store.ts' + +afterEach(cleanup) + +const SCHEMA = { + uid: 4, + 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 } }, + }, +} + +function view(defaultPreset: string, revision = 0): SettingsNamespaceView { + return { + ns: 'permission', + schema: SCHEMA, + value: { defaultPreset }, + base: { defaultPreset: 'read-only' }, + applies: 'live', + secrets: [], + revision, + } +} + +function ok(value: T) { + return { rpcId: 'test', result: { ok: true as const, value } } +} + +const dictionary: Record = en +const t: PermissionRowProps['t'] = key => dictionary[key] ?? key +const runtime = { + useSessions: (() => { throw new Error('unused') }) as never, + useWorkspaces: (() => { throw new Error('unused') }) as never, +} + +function mount(controller: PermissionSettingsController) { + return render( + , + ) +} + +describe('PermissionRow', () => { + it('loads the descriptor, opens the menu, and selects a new default', async () => { + const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1)))) + const controller = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate, + } as never, + }) + mount(controller) + const button = await screen.findByRole('button', { name: 'Read Only' }) + expect(button.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(button) + expect(button.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) + fireEvent.click(button) + fireEvent.click(button) + expect(button.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) + await screen.findByRole('button', { name: 'Workspace Write' }) + expect(mutate).toHaveBeenCalledOnce() + }) + + it('hides an unavailable namespace and disables a read-only provider', async () => { + const absent = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [] })), + mutate: vi.fn(), + } as never, + }) + const rendered = mount(absent) + await waitFor(() => { expect(rendered.container.textContent).toBe('') }) + rendered.unmount() + + const readonly = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: false, namespaces: [view('read-only')] })), + mutate: vi.fn(), + } as never, + }) + mount(readonly) + expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true) + }) + + it('shows loading and a contained write error', async () => { + const describe = Promise.withResolvers>>() + const controller = new PermissionSettingsController({ + settings: { + describe: () => describe.promise, + mutate: () => Promise.resolve({ + rpcId: 'test', + result: { + ok: false as const, + error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} }, + }, + }), + } as never, + }) + mount(controller) + expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true) + describe.resolve(ok({ writable: true, namespaces: [view('read-only')] })) + const button = await screen.findByRole('button', { name: 'Read Only' }) + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) + expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere') + }) +}) diff --git a/packages/client/ui-permission/tests/settings-store.spec.ts b/packages/client/ui-permission/tests/settings-store.spec.ts new file mode 100644 index 0000000000..74edb838b0 --- /dev/null +++ b/packages/client/ui-permission/tests/settings-store.spec.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + PermissionSettingsController, permissionDefaultOf, refreshPermissionIfLoaded, +} from '../src/client/settings-store.ts' + +const SCHEMA = { + uid: 6, + refs: { + 1: { type: 'const', value: 'read-only' }, + 2: { type: 'const', meta: { description: 'Workspace' }, value: 'workspace-write' }, + 3: { type: 'union', list: [1, 2] }, + 6: { type: 'object', dict: { defaultPreset: 3 } }, + }, +} + +function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView { + return { + ns: 'permission', + schema, + value: { defaultPreset }, + base: { defaultPreset: 'read-only' }, + applies: 'live', + secrets: [], + revision, + } +} + +function ok(value: T) { + return { rpcId: 'test', result: { ok: true as const, value } } +} + +describe('permission settings store', () => { + it('derives dynamic options and host labels from the descriptor schema', () => { + expect(permissionDefaultOf(view('read-only'))).toEqual({ + currentValue: 'read-only', + options: [ + { id: 'read-only', label: 'Read Only' }, + { id: 'workspace-write', label: 'Workspace' }, + ], + }) + const single = { + uid: 2, + refs: { + 1: { type: 'const', meta: { description: '' }, value: 'read-only' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + } + expect(permissionDefaultOf(view('read-only', 0, single))).toEqual({ + currentValue: 'read-only', + options: [{ id: 'read-only', label: 'Read Only' }], + }) + const undescribed = { + uid: 2, + refs: { + 1: { type: 'const', meta: { description: 7 }, value: 'read-only' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + } + expect(permissionDefaultOf(view('read-only', 0, undescribed)).options) + .toEqual([{ id: 'read-only', label: 'Read Only' }]) + }) + + it('rejects malformed values and dynamic enums at the wire boundary', () => { + expect(() => permissionDefaultOf({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 1, refs: { 1: { type: 'object', dict: {} } }, + }))).toThrow(/no defaultPreset field/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 2, + refs: { + 1: { type: 'union' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + }))).toThrow(/does not advertise/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 4, + refs: { + 1: { type: 'string' }, + 2: { type: 'const', value: 1 }, + 3: { type: 'union', list: [1, 2] }, + 4: { type: 'object', dict: { defaultPreset: 3 } }, + }, + }))).toThrow(/does not advertise/) + expect(() => permissionDefaultOf(view('missing'))).toThrow(/does not advertise/) + }) + + it('loads and writes defaultPreset with optimistic concurrency', async () => { + const describe = vi.fn(() => Promise.resolve(ok({ + writable: true, + namespaces: [view('read-only', 4)], + }))) + const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5)))) + const controller = new PermissionSettingsController({ + settings: { describe, mutate } as never, + }) + await controller.load() + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', + writable: true, + currentValue: 'read-only', + revision: 4, + }) + await controller.select('workspace-write') + expect(mutate).toHaveBeenCalledWith({ + ns: 'permission', + ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], + expectedRevision: 4, + }) + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', + currentValue: 'workspace-write', + revision: 5, + }) + }) + + it('hides the row when the namespace is absent and contains write failures', async () => { + const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [] }))) + const controller = new PermissionSettingsController({ + settings: { describe, mutate: vi.fn() } as never, + }) + await controller.load() + expect(controller.store.getSnapshot().status).toBe('unavailable') + + const failing = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate: () => Promise.resolve({ + rpcId: 'test', + result: { + ok: false as const, + error: { code: 'settings-conflict', message: 'stale', details: {} }, + }, + }), + } as never, + }) + await failing.load() + await failing.select('workspace-write') + expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'stale' }) + }) + + it('contains read failures, no-ops without a writable view, and ignores stale responses', async () => { + const first = Promise.withResolvers>>() + const describe = vi.fn() + .mockImplementationOnce(() => first.promise) + .mockResolvedValueOnce(ok({ writable: false, namespaces: [view('read-only', 2)] })) + const mutate = vi.fn() + const controller = new PermissionSettingsController({ + settings: { describe, mutate } as never, + }) + const stale = controller.load() + await controller.load() + first.resolve(ok({ writable: true, namespaces: [view('workspace-write', 1)] })) + await stale + expect(controller.store.getSnapshot()).toMatchObject({ + currentValue: 'read-only', + writable: false, + revision: 2, + }) + await controller.select('workspace-write') + expect(mutate).not.toHaveBeenCalled() + + const rejected = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve({ + rpcId: 'test', + result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } }, + }), + mutate, + } as never, + }) + await rejected.select('workspace-write') + await rejected.load() + expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' }) + + const thrown = new PermissionSettingsController({ + settings: { + // Promise consumers must contain unknown rejection values from a + // transport implementation, including non-Error legacy clients. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + describe: () => Promise.reject('disconnected'), + mutate, + } as never, + }) + await thrown.load() + expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) + }) + + it('disposal suppresses in-flight reads and writes, and loaded invalidations refetch', async () => { + const read = Promise.withResolvers>>() + const describe = vi.fn(() => read.promise) + const idle = new PermissionSettingsController({ settings: { describe, mutate: vi.fn() } as never }) + refreshPermissionIfLoaded(idle) + expect(describe).not.toHaveBeenCalled() + const loading = idle.load() + idle.dispose() + read.resolve(ok({ writable: true, namespaces: [view('read-only')] })) + await loading + expect(idle.store.getSnapshot().status).toBe('loading') + + const rejectedRead = Promise.withResolvers>>() + const disposedRead = new PermissionSettingsController({ + settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never, + }) + const reading = disposedRead.load() + disposedRead.dispose() + rejectedRead.reject(new Error('late read')) + await reading + expect(disposedRead.store.getSnapshot().status).toBe('loading') + + const mutation = Promise.withResolvers>>() + const activeDescribe = vi.fn(() => Promise.resolve(ok({ + writable: true, + namespaces: [view('read-only')], + }))) + const active = new PermissionSettingsController({ + settings: { + describe: activeDescribe, + mutate: () => mutation.promise, + } as never, + }) + await active.load() + refreshPermissionIfLoaded(active) + await vi.waitFor(() => { expect(activeDescribe).toHaveBeenCalledTimes(2) }) + const saving = active.select('workspace-write') + active.dispose() + mutation.resolve(ok(view('workspace-write', 1))) + await saving + expect(active.store.getSnapshot().status).toBe('saving') + + const rejectedMutation = Promise.withResolvers>>() + const disposedWrite = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate: () => rejectedMutation.promise, + } as never, + }) + await disposedWrite.load() + const writing = disposedWrite.select('workspace-write') + disposedWrite.dispose() + rejectedMutation.reject(new Error('late write')) + await writing + expect(disposedWrite.store.getSnapshot().status).toBe('saving') + }) +}) diff --git a/packages/client/ui-permission/tsconfig.json b/packages/client/ui-permission/tsconfig.json index b66ce746b2..32f84d6a2d 100644 --- a/packages/client/ui-permission/tsconfig.json +++ b/packages/client/ui-permission/tsconfig.json @@ -8,18 +8,36 @@ "src" ], "references": [ + { + "path": "../connection" + }, + { + "path": "../locale" + }, { "path": "../../../vendor/cordis" }, { "path": "../runtime" }, + { + "path": "../schema-form" + }, { "path": "../ui-command" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slash" }, + { + "path": "../ui-slots" + }, + { + "path": "../web-react" + }, { "path": "../../ui/permission" }, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 9377fc73b8..8beb4c9578 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: c392d745021c0fc6a752cf71dd0506a435106c50 -README.zh.md: 83ab81e01eae435a74b50fa363a4de203c483002 +# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md +README.md: 9e12f02fc1e767fb807be4fdd3f506c189662bc7 +README.zh.md: 225e27f5705f33bc6199615b0fe96e04eaa6a04c diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index c392d74502..9e12f02fc1 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), and the `settings` dictionaries. Feature-owned rows (Language, Appearance) and sections (Models) stay with their feature packages. +Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (`settings.general.item` slot plus the Tool Call skeleton), and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages. ## Model Experience @@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing. When they gain real backing, each moves to its owning feature plugin per the self-registration doctrine. +- **Tool Call is a display skeleton** — its backing host setting does not exist yet, so the cubes write nothing. When it gains real backing, the row moves to its owning feature plugin per the self-registration doctrine. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 83ab81e01e..225e27f570 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置界面文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明),以及 `settings` 字典。归具体功能所有的行(「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 +设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(`settings.general.item` slot 加上「工具调用」骨架行),以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 ## 模型体验 @@ -14,4 +14,4 @@ ## 已知限制与暂缓事项 -- **「权限」与「工具调用」只是展示骨架**:对应的宿主服务和 RPC 方法尚不存在;这些控件已禁用,不会写入任何内容。一旦获得实际支撑,按照自注册原则,每一项都会移至拥有它的功能插件。 +- **「工具调用」只是展示骨架**:其宿主设置尚不存在,因此控件不会写入任何内容。一旦获得实际支撑,按照自注册原则,该行会移至拥有它的功能插件。 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 798a7710f0..6d0ee01404 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", - "description": "Settings ownerless-copy plugin: the General section (skeleton rows + item slot), the shell trigger/header chrome content, and the settings dictionaries", + "description": "Settings ownerless-copy plugin: the General section and Tool Call skeleton, shell trigger/header chrome content, and settings dictionaries", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index aced3b2962..cb1d137ec8 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -13,15 +13,6 @@ border-bottom: none; } -/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */ -.row { - display: flex; - align-items: center; - gap: 8px; - padding: 16px 0; - border-bottom: 1px solid var(--dsw-alias-border-l2); -} - /* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */ .group { display: flex; @@ -31,16 +22,6 @@ border-bottom: 1px solid var(--dsw-alias-border-l2); } -/* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */ -.rowText { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; - padding-right: 48px; -} - .title { font-size: 14px; font-weight: 400; @@ -55,35 +36,6 @@ color: var(--dsw-alias-label-tertiary); } -/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */ -.selector { - display: inline-flex; - align-items: center; - gap: 12px; - height: 36px; - padding: 0 14px; - border: none; - border-radius: 18px; - background: var(--dsw-alias-bg-module-platform); - font: inherit; - font-size: 14px; - line-height: 22px; - color: var(--dsw-alias-label-primary); - cursor: pointer; -} - -.selector:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.selector:disabled { - cursor: default; -} - -.chevron { - flex: none; -} - /* Tool Call mode cubes share an 8px gap and wrap to one per row when the panel is too narrow. */ .cubeRow { diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx index 62c3a0b134..861f4e23d4 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.tsx +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -1,55 +1,51 @@ /** - * The General section (figma 501:29983 'Options'): Permission and Tool Call - * skeleton rows, then the feature-contributed preference rows from the - * `settings.general.item` slot (locale → Language, ui-theme → Appearance). - * The section column stacks rows; each row draws its own internals and - * separator. + * The General section (figma 501:29983 'Options'): one column rendering the + * `settings.general.item` contributions. Features own their rows; this + * package contributes only the ownerless Tool Call skeleton. */ -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import css from './GeneralSection.module.css' -/** Full component props: section owner share + item render share + the standard locale seat. */ +/** Full component props: section owner share plus item render share. */ export type GeneralSectionComponentProps = - PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & PropsLocale<'settings'> + PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> /** * Render the General section content column. * @param props - composed slot props (contract/slots.ts). * @returns the section element tree. */ -export function GeneralSection({ t, renderSlot }: GeneralSectionComponentProps) { +export function GeneralSection({ renderSlot }: GeneralSectionComponentProps) { return (
- {/* Permission (skeleton): disabled selector pill. */} -
-
-
{t('permission.title')}
-
{t('permission.desc')}
-
- -
- - {/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */} -
-
{t('toolcall.title')}
-
-
-
{t('toolcall.schema.title')}
-
{t('toolcall.schema.desc')}
-
-
-
{t('toolcall.code.title')}
-
{t('toolcall.code.desc')}
-
-
-
- - {/* Feature-owned preference rows (Language, Appearance, …). */} {renderSlot('settings.general.item', {})}
) } + +/** Props of the ownerless Tool Call item contribution. */ +export type ToolCallSkeletonProps = + PropsRuntime<'settings.general.item'> & PropsLocale<'settings'> + +/** + * Render the static Tool Call mode choice until its host setting exists. + * @param props - item runtime and translated copy. + * @returns the skeleton row. + */ +export function ToolCallSkeleton({ t }: ToolCallSkeletonProps) { + return ( +
+
{t('toolcall.title')}
+
+
+
{t('toolcall.schema.title')}
+
{t('toolcall.schema.desc')}
+
+
+
{t('toolcall.code.title')}
+
{t('toolcall.code.desc')}
+
+
+
+ ) +} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 2683c20608..894b3a50e9 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,9 +1,9 @@ /** * Settings ownerless-copy plugin, browser half: registers everything on the * Settings surface that belongs to no single feature — the trigger/header - * chrome content, the General section (skeleton rows + the - * `settings.general.item` slot declaration), and the `settings` - * dictionaries. Feature-owned rows and sections stay with their features. + * chrome content, the General section (`settings.general.item` slot plus the + * ownerless Tool Call skeleton), and the `settings` dictionaries. + * Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' @@ -13,13 +13,15 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge. import type {} from '@deepseek-ai/dsh-client-locale/client' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' -import { GeneralSection } from './GeneralSection.tsx' +import { GeneralSection, ToolCallSkeleton } from './GeneralSection.tsx' import { en, zh, type SettingsKey } from './locales.ts' export type { CloseLabelProps, HeaderContentProps, TriggerContentProps, } from './chrome.tsx' -export type { GeneralSectionComponentProps } from './GeneralSection.tsx' +export type { + GeneralSectionComponentProps, ToolCallSkeletonProps, +} from './GeneralSection.tsx' export type { SettingsKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -67,11 +69,19 @@ export function apply(ctx: ClientContext): void { locale: NS, children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, }, GeneralSection)) + const toolCall = deferRegistration(ctx.slots, 'settings.general.item', ToolCallSkeleton, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'tool-call', + order: -10, + locale: NS, + }, ToolCallSkeleton)) return () => { trigger.dispose() header.dispose() close.dispose() general.dispose() + toolCall.dispose() } }, 'ui-settings-general: chrome and section registrations') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index d49dfecf96..1e3ff3b883 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -1,12 +1,10 @@ /** * `settings` namespace dictionaries: shell chrome plus the shell-owned - * General section (nav label, skeleton rows). Skeleton-row technical copy - * (Read only / Schema mode / Code mode and their descriptions) is shared - * verbatim across locales per the Figma design. Feature-owned rows - * (Language, Appearance) ship their copy in their own packages. + * General section (nav label and ownerless Tool Call skeleton). Technical + * mode copy is shared verbatim across locales per the Figma design. + * Feature-owned rows ship their copy in their own packages. */ const SHARED = { - 'permission.value': 'Read only', 'toolcall.schema.title': 'Schema mode', 'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time', 'toolcall.code.title': 'Code mode', @@ -20,8 +18,6 @@ export const zh = { 'title': '设置', 'close': '关闭', 'general.nav': '通用设置', - 'permission.title': '权限', - 'permission.desc': '选择默认权限模式', 'toolcall.title': '工具调用', } satisfies Record @@ -35,7 +31,5 @@ export const en = { 'title': 'Settings', 'close': 'Close', 'general.nav': 'General', - 'permission.title': 'Permission', - 'permission.desc': 'Choose default permission mode', 'toolcall.title': 'Tool Call', } satisfies Record diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 726efe0be8..dfbaa49c6e 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,12 +1,12 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' -import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' /** The four seats this plugin fills (slot name → expected component). */ const SEATS = [ @@ -61,10 +61,16 @@ describe('ui-settings-general apply', () => { // The nav label is a locale-following thunk; owners resolve at read time. expect(resolveSlotLabel(entry.options.label)).toBe('通用设置') expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + const toolEntry = before.slots.entries('settings.general.item')[0]! + expect(toolEntry).toMatchObject({ + component: ToolCallSkeleton, + options: { id: 'tool-call', order: -10 }, + }) // Copy rides the standard locale seat: every seat declares the namespace. for (const [name] of SEATS) { expect(before.slots.entries(name)[0]!.locale).toBe('settings') } + expect(toolEntry.locale).toBe('settings') const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() @@ -76,6 +82,9 @@ describe('ui-settings-general apply', () => { // The self-inflicted ledger notifications hit the duplicate guard. expect(after.slots.entries(name)).toHaveLength(1) } + await vi.waitFor(() => { + expect(after.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) + }) }) it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => { @@ -124,6 +133,7 @@ describe('ui-settings-general apply', () => { for (const [name, component] of SEATS) { expect(b.slots.entries(name)[0]!.component).toBe(component) } + expect(b.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) // The recovered registrations still ride the locale path. b.locale.setLocale('en') diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 9af2fb825b..2538335581 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -1,8 +1,10 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' -import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' -import { GeneralSection } from '../src/client/GeneralSection.tsx' +import type { + GeneralSectionComponentProps, ToolCallSkeletonProps, +} from '../src/client/GeneralSection.tsx' +import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { en } from '../src/client/locales.ts' @@ -10,7 +12,7 @@ afterEach(cleanup) // The seat's key domain is settings ∪ common; the stub answers from the // package dictionary and falls back to the key like the real chain. -const t: GeneralSectionComponentProps['t'] = key => (en as Record)[key] ?? key +const t: ToolCallSkeletonProps['t'] = key => (en as Record)[key] ?? key // Global standard kit stubs: none of these components consume the hooks. const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never @@ -42,21 +44,21 @@ describe('GeneralSection', () => { const renderSlot = vi.fn( ((key: string) =>
) as GeneralSectionComponentProps['renderSlot'], ) - const props: GeneralSectionComponentProps = { ...kit, t, renderSlot } + const props: GeneralSectionComponentProps = { ...kit, renderSlot } const view = render() return { view, renderSlot } } - it('renders the Permission skeleton row with the disabled selector', () => { - mount() - expect(screen.getByText('Permission')).toBeTruthy() - expect(screen.getByText('Choose default permission mode')).toBeTruthy() - const selector = screen.getByRole('button', { name: /Read only/ }) - expect(selector.disabled).toBe(true) + it('renders the item slot as the section body', () => { + const { renderSlot } = mount() + expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {}) + expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() }) +}) - it('renders the Tool Call skeleton cubes with schema pinned selected', () => { - mount() +describe('ToolCallSkeleton', () => { + it('renders the mode cubes with schema pinned selected', () => { + render() expect(screen.getByText('Tool Call')).toBeTruthy() const schema = screen.getByText('Schema mode') const code = screen.getByText('Code mode') @@ -65,10 +67,4 @@ describe('GeneralSection', () => { expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy() expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy() }) - - it('renders the feature-contributed item slot after the skeleton rows', () => { - const { renderSlot } = mount() - expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {}) - expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() - }) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 02cf787a27..3d9399cc5c 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 73d8afb32f868ca82dfa2d350df089a5d0b9b358 -README.zh.md: 47af18f76302e261e18f682e0d3cf0ee903933db +README.md: eba4ad8406a4b3426c04d8da37f68a1306b5382d +README.zh.md: 4fdbc889d297d810053836566010bac35839b8da diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 73d8afb32f..eba4ad8406 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -34,7 +34,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves exactly the namespaces a registered configurable provider addresses (`ctx.llm.listConfigurableProviders()`): the seam is general, but this plane is the model-provider surface, so a namespace nothing in the directory names is neither described nor writable here and answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired both by `llm/adapters-updated` and by a change to an exposed provider namespace, whose settings carry that provider's catalog and endpoint. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit Web-preference allowlist, currently only `permission`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 47af18f763..4fdbc889d2 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -34,7 +34,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域只服务于已注册可配置提供方所指向的那些 namespace(`ctx.llm.listConfigurableProviders()`):seam 本身是通用的,但这个面是模型提供方表层,因此目录中无人点名的 namespace 在这里既不会被描述也不可写入,只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它既由 `llm/adapters-updated` 触发,也由某个已暴露提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 Web 偏好 allowlist,目前仅包含 `permission`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e224608c72..205c9912f5 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -72,6 +72,9 @@ import { openNativePath } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 +/** Non-model settings namespaces intentionally served to the Web client. */ +const WEB_SETTINGS_NAMESPACES = ['permission'] as const + /** Provider work budget: at most 100 calls and 2,000 inspected hits. */ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 @@ -1098,31 +1101,35 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } - /** - * The settings namespaces this proxy serves: exactly those a registered - * configurable provider addresses. The settings seam itself is general — - * any plugin may register a namespace for its own configuration — but the - * Web configuration plane is scoped to model providers, and that boundary - * has to be enforced here rather than assumed from the current plugin set. - * Without it, every future `settings.register()` would silently become - * remotely readable and writable configuration. - */ - function exposedNamespaces(): Set { + /** Settings namespaces whose changes can invalidate the model catalog. */ + function modelProviderNamespaces(): Set { return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs)) } - /** Refuse a namespace outside the model-provider boundary, naming why. */ + /** + * The settings namespaces this proxy serves: configurable model providers + * plus the small explicit Web preference allowlist. The settings seam + * remains general; a future registration does not become remotely readable + * or writable by default. + */ + function exposedNamespaces(): Set { + const exposed = modelProviderNamespaces() + for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns) + return exposed + } + + /** Refuse a namespace outside the explicit configuration-client boundary. */ function notExposed(request: RpcRequest, ns: string): RpcResponse { return err(request, { code: 'settings-not-exposed', - message: `settings namespace "${ns}" is not exposed to configuration clients; only a namespace a registered model provider addresses is`, + message: `settings namespace "${ns}" is not exposed to configuration clients`, details: { ns }, }) } /** * Run one settings write (merge or wholesale replace) and acknowledge with - * the namespace's new redacted view. A namespace outside the model-provider + * the namespace's new redacted view. A namespace outside the configuration * boundary is refused before the seam is touched; every seam refusal — * unknown or invalid namespace, read-only provider, schema validation, * storage — becomes one `settings-rejected` carrying the seam's own message. @@ -2165,11 +2172,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // inherited to overridden leaves the resolved value equal, and a // configuration client still has to re-read (its held revision is // stale, and the field's meaning changed). - queue.push(frame({ type: 'host/settings-changed', ns: String(ns) })) + const name = String(ns) + queue.push(frame({ type: 'host/settings-changed', ns: name })) // A provider's own settings carry its model catalog and endpoint, // so a change there invalidates the model list even when the route // set is untouched — `llm/adapters-updated` alone misses it. - if (exposedNamespaces().has(String(ns))) queue.push(frame({ type: 'host/models-changed' })) + if (modelProviderNamespaces().has(name)) queue.push(frame({ type: 'host/models-changed' })) }), ctx.on('credentials/updated', (ref) => { queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) })) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index a505f72018..08d2dee2f3 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -160,8 +160,8 @@ async function harness(options?: { await ctx.plugin(LlmService) if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings) if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials) - // The proxy serves only namespaces a configurable provider addresses, which - // is what the real LLM plugins declare at load; the tests mirror that. + // Model-provider namespaces and the explicit Web preference allowlist are + // the proxy's complete settings surface. if (options?.configurableProviders !== false) { ctx.llm.registerConfigurableProviders([ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, @@ -222,19 +222,29 @@ describe('settings domain', () => { expect(JSON.stringify(value)).not.toContain('user-secret') }) - it('serves only namespaces a registered model provider addresses', async () => { + it('serves model-provider and explicitly allowlisted Web namespaces only', async () => { // The settings seam is general: any plugin may register a namespace for - // its own configuration. The Web configuration plane is not — it is the - // model-provider surface, and a namespace nothing in the provider - // directory addresses must be invisible and unwritable here, so a future - // plugin cannot become remotely configurable just by registering. + // its own configuration. The Web configuration plane remains opt-in, so a + // future internal plugin cannot become remotely configurable just by + // registering; permission is the one non-model namespace intentionally + // admitted by this surface. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) + ctx.settings.register(settingsNamespace('permission'), z.object({ + defaultPreset: z.union(['read-only', 'workspace-write']).required(), + }), { + base: { defaultPreset: 'read-only' }, + }) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.settings.describe(request({}))) - expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek']) + expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission']) + const permission = expectOk(await api.settings.mutate(request({ + ns: 'permission', + ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], + }))) + expect(permission.value).toEqual({ defaultPreset: 'workspace-write' }) for (const response of [ await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })), @@ -277,6 +287,20 @@ describe('settings domain', () => { .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) }) + it('broadcasts a permission change without invalidating the model catalog', async () => { + const ctx = await harness() + const permission = ctx.settings.register(settingsNamespace('permission'), z.object({ + defaultPreset: z.union(['read-only', 'workspace-write']).required(), + }), { + base: { defaultPreset: 'read-only' }, + }) + const api = createApiProxy(ctx, DEFAULTS) + const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 1, async () => { + await permission.update({ defaultPreset: 'workspace-write' }) + }) + expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }]) + }) + it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig) diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml index f6f5f49a14..e23eed90a4 100644 --- a/packages/ui/permission/README.i18n.yaml +++ b/packages/ui/permission/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/permission/README.md -README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4 -README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa +README.md: 576555b56c82e4041f2862bcb41ce137cb2d4f77 +README.zh.md: 894289314a4fb2fc22216462d3f3e0544d7d17bb diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 814085ed6f..576555b56c 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -6,7 +6,9 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s `set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. -The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). +The service owns the `permission` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permission/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed preserves its effective permission and receives only missing durable facts rather than the latest user default. + +The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md index 36880d6b8c..894289314a 100644 --- a/packages/ui/permission/README.zh.md +++ b/packages/ui/permission/README.zh.md @@ -6,7 +6,9 @@ `set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 -该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed 会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。 + +该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元折叠三个全量值旋钮事件,在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index c5021af37b..c3554e3c4d 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-projection": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 5c18a2b560..3919db66f6 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -21,6 +21,7 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a import type {} from '@deepseek-ai/dsh-bash' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' // Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children. import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-commands' @@ -68,6 +69,9 @@ export interface PresetSpec { */ export const CUSTOM_PRESET = 'custom' +/** Settings namespace carrying the default for future sessions. */ +export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission') + /** * Fold the last selected preset from the durable log; replay needs no catch-up * state. @@ -126,7 +130,13 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState { return state } -/** The {@link PermissionService} config: the deployment's preset table. */ +/** User setting resolved when a new session receives its initial permission. */ +export interface PermissionSettings { + /** Preset pinned into a newly created session. */ + defaultPreset: string +} + +/** The {@link PermissionService} config: preset table and composition default. */ export interface Config { /** * The preset table: name → knob bundle. Defaults to `workspace-write` @@ -134,6 +144,11 @@ export interface Config { * never). The name `custom` is reserved for the derived not-a-preset state. */ presets?: Record + /** + * Default for new sessions. When omitted, the preset matching the composed + * sandbox and approval defaults is used. + */ + defaultPreset?: string } /** @@ -159,11 +174,13 @@ export class PermissionService extends Service { name: 'danger-full-access', description: 'Full file access without approval prompts.', }, }), + defaultPreset: z.string(), }) static inject = ['bash', 'approval'] private readonly presets: Record + private defaultSettings: () => PermissionSettings constructor(ctx: Context, config: Config) { super(ctx, 'permission') @@ -175,6 +192,34 @@ export class PermissionService extends Service { if (ctx.bash.sandboxMode === undefined) { throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration') } + const inferredDefault = this.derive(EMPTY_KNOBS) + const defaultPreset = config.defaultPreset ?? inferredDefault + if (defaultPreset === CUSTOM_PRESET) { + throw new Error('permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly') + } + this.resolve(defaultPreset) + const baseSettings: PermissionSettings = { defaultPreset } + this.defaultSettings = () => baseSettings + const presetChoices = this.names.map((name) => { + const choice = z.const(name) + const label = this.presets[name]?.name + return label === undefined ? choice : choice.description(label) + }) + const settingsSchema: z = z.object({ + defaultPreset: z.union(presetChoices).required(), + }) + installSettingsSection(ctx, PERMISSION_SETTINGS_NAMESPACE, settingsSchema, baseSettings, { + setSource: (current) => { + this.defaultSettings = current + }, + // The source thunk reads the latest scope snapshot at session creation; + // no process-level registration needs replacement on change. + onChange: () => {}, + }) + + ctx.on('session/created', (session) => { + this.pinInitialPermission(session) + }) // The permissions projection unit: fold the three whole-value knob // events; view derives the select over the composition defaults this @@ -237,6 +282,15 @@ export class PermissionService extends Service { return Object.keys(this.presets) } + /** + * The preset currently selected as the default for future sessions. + * @returns the resolved settings value, or the composition default without + * a mounted settings provider. + */ + get defaultPreset(): string { + return this.defaultSettings().defaultPreset + } + /** * Resolve the preset matching the effective knob values. A still-matching * last selection wins shared-bundle ties; otherwise the first table match @@ -328,6 +382,44 @@ export class PermissionService extends Service { setApprovalPolicy(session, spec.approval) } } + + /** + * Fill every missing permission fact before a session is published. A + * genuinely fresh session uses the current user default; seeded or partially + * initialized sessions preserve their effective knob values and only gain + * the missing durable facts. + */ + private pinInitialPermission(session: Session): void { + const events = session.events + const selected = effectivePermissionPreset(events) + const sandbox = effectiveSandboxMode(events) + const approval = effectiveApprovalPolicy(events) + const seeded = events.some(event => event.type === 'session/end-seed') + if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) { + const name = this.defaultPreset + const spec = this.resolve(name) + session.append('permission/preset', { preset: name }) + setSandboxMode(session, spec.sandbox) + setApprovalPolicy(session, spec.approval) + return + } + + const state: KnobState = { + preset: selected ?? null, + sandbox: sandbox ?? null, + approval: approval ?? null, + } + const effective = this.derive(state) + if (selected === undefined && effective !== CUSTOM_PRESET) { + session.append('permission/preset', { preset: effective }) + } + if (sandbox === undefined) { + setSandboxMode(session, this.ctx.bash.sandboxMode as SandboxMode) + } + if (approval === undefined) { + setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask') + } + } } export default PermissionService diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 05a747de8d..b7a4203745 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -1,10 +1,29 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' -import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission' +import PermissionService, { + CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE, +} from '@deepseek-ai/dsh-permission' import type { Config } from '@deepseek-ai/dsh-permission' +import { Settings } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' + +/** Writable memory provider for the permission/settings lifecycle specs. */ +class MemorySettings extends Settings { + readonly doc: Record = {} + readonly writable = true + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc[ns] = structuredClone(section) + return Promise.resolve() + } +} async function mounted(options: { config?: Config @@ -27,6 +46,23 @@ function freshSession(id: string): Session { return new Session(SessionId(id)) } +async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemorySettings) + ctx.provide('bash', { + sandboxMode: 'workspace-write', + resolve() { throw new Error('permission tests do not execute bash') }, + run() { throw new Error('permission tests do not execute bash') }, + start() { throw new Error('permission tests do not execute bash') }, + }) + ctx.provide('approval', { + config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' }, + }) + await ctx.plugin(PermissionService, {}) + return ctx +} + describe('effectivePermissionPreset', () => { it('folds to the last event, or undefined without one', () => { const session = freshSession('sess-fold') @@ -66,8 +102,11 @@ describe('PermissionService', () => { expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/) }) - it('composition defaults outside the table derive custom at zero events', async () => { - const ctx = await mounted({ approvalDefault: 'never' }) + it('composition defaults outside the table still derive custom when an explicit new-session default is configured', async () => { + const ctx = await mounted({ + approvalDefault: 'never', + config: { defaultPreset: 'workspace-write' }, + }) const session = freshSession('sess-defaults-custom') expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) }) @@ -138,6 +177,11 @@ describe('PermissionService', () => { .rejects.toThrow(/reserved for the derived not-a-preset state/) }) + it('requires an explicit default when composition defaults match no preset', async () => { + await expect(mounted({ approvalDefault: 'never' })) + .rejects.toThrow(/configure defaultPreset explicitly/) + }) + it('reads a schema-less approval stand-in as the ask default', async () => { const ctx = await mounted({ approvalDefault: undefined }) const session = freshSession('sess-standin') @@ -146,3 +190,79 @@ describe('PermissionService', () => { expect(ctx.permission.current(session.events)).toBe('workspace-write') }) }) + +describe('new-session default', () => { + it('pins the current setting into each new session without changing earlier sessions', async () => { + const ctx = await mountedStore() + const first = ctx.sessions.create(SessionId('first')) + expect(first.events.map(event => [event.type, event.data])).toEqual([ + ['permission/preset', { preset: 'workspace-write' }], + ['sandbox/mode', { mode: 'workspace-write' }], + ['approval/policy', { policy: 'ask' }], + ]) + + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + expect(ctx.permission.defaultPreset).toBe('danger-full-access') + const second = ctx.sessions.create(SessionId('second')) + expect(ctx.permission.current(first.events)).toBe('workspace-write') + expect(ctx.permission.current(second.events)).toBe('danger-full-access') + expect(second.events.map(event => event.type)).toEqual([ + 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + }) + + it('preserves a seeded legacy session instead of applying the latest user default', async () => { + const ctx = await mountedStore() + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + const legacy = freshSession('legacy-source') + legacy.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events }) + expect(ctx.permission.current(resumed.events)).toBe('workspace-write') + expect(resumed.events.slice(-3).map(event => event.type)).toEqual([ + 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + }) + + it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => { + const ctx = await mountedStore() + const partial = freshSession('partial-source') + partial.append('sandbox/mode', { mode: 'workspace-write' }) + partial.append('approval/policy', { policy: 'ask' }) + const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.events }) + expect(resumed.events.at(-1)).toMatchObject({ + type: 'permission/preset', + data: { preset: 'workspace-write' }, + }) + + const custom = freshSession('custom-source') + custom.append('sandbox/mode', { mode: 'read-only' }) + custom.append('approval/policy', { policy: 'never' }) + const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events }) + expect(ctx.permission.current(unmatched.events)).toBe(CUSTOM_PRESET) + expect(unmatched.events.at(-1)?.type).toBe('session/end-seed') + }) + + it('materializes ask when a legacy seed and approval stand-in omit the policy', async () => { + const ctx = await mountedStore({ approvalDefault: undefined }) + const partial = freshSession('approval-fallback-source') + partial.append('sandbox/mode', { mode: 'workspace-write' }) + const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.events }) + expect(resumed.events.at(-1)).toMatchObject({ + type: 'approval/policy', + data: { policy: 'ask' }, + }) + }) + + it('rejects a stored default outside the configured preset table', async () => { + const ctx = await mountedStore() + await expect(ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'missing', + })).rejects.toThrow() + expect(ctx.permission.defaultPreset).toBe('workspace-write') + }) +}) diff --git a/packages/ui/permission/tests/projection.spec.ts b/packages/ui/permission/tests/projection.spec.ts index 1649fe7077..a50c17a399 100644 --- a/packages/ui/permission/tests/projection.spec.ts +++ b/packages/ui/permission/tests/projection.spec.ts @@ -44,7 +44,7 @@ async function agentFor(ctx: Context, session: Session): Promise { } describe('permissions projection unit', () => { - it('serves the composition-default select at zero events', async () => { + it('serves the pinned new-session default select', async () => { const { ctx, session } = await harness() const value = ctx.sessionProjections.snapshot(session).values.permissions expect(value).toMatchObject({ currentValue: 'workspace-write' }) @@ -103,12 +103,14 @@ describe('/permission command', () => { kind: 'success', text: 'current preset workspace-write (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0) + expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(1) }) it('rejects an unknown preset without touching the log', async () => { const { ctx, session } = await harness() const agent = await agentFor(ctx, session) + const before = session.events.filter(event => + event.type !== 'command/run' && event.type !== 'command/done') const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal) // The error text carries the same no-self-labelling rule as the success // texts: `permission · unknown preset "yolo" (…)`, not `unknown permission @@ -117,6 +119,7 @@ describe('/permission command', () => { kind: 'error', text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0) + expect(session.events.filter(event => + event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before) }) }) diff --git a/packages/ui/permission/tsconfig.json b/packages/ui/permission/tsconfig.json index 493fbf358e..9dc4afcd9a 100644 --- a/packages/ui/permission/tsconfig.json +++ b/packages/ui/permission/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../settings/settings" + }, { "path": "../commands" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 545d5c7eae..96a8df6f8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1413,24 +1413,48 @@ importers: packages/client/ui-permission: devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-schema-form': + specifier: workspace:^ + version: link:../schema-form '@deepseek-ai/dsh-client-ui-command': specifier: workspace:^ version: link:../ui-command + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slash': specifier: workspace:^ version: link:../ui-slash + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-permission': specifier: workspace:^ version: link:../../ui/permission + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-plan: devDependencies: @@ -5431,6 +5455,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval From f45b6f76a5fcd4198f49c572f4dd57a6fda9d1cf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 12:48:39 +0800 Subject: [PATCH 2/5] fix(web): remove tool-call settings placeholder --- ...mission-default-for-new-sessions.i18n.yaml | 4 +- ...-31-permission-default-for-new-sessions.md | 2 +- ...-permission-default-for-new-sessions.zh.md | 2 +- apps/web/tests/settings-chrome.e2e.ts | 5 +- .../settings-chrome/dialog.expected.md | 2 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 4 +- .../client/ui-settings-general/README.zh.md | 4 +- .../client/ui-settings-general/package.json | 2 +- .../src/client/GeneralSection.module.css | 67 +------------------ .../src/client/GeneralSection.tsx | 35 +--------- .../ui-settings-general/src/client/index.ts | 15 +---- .../ui-settings-general/src/client/locales.ts | 17 +---- .../ui-settings-general/tests/apply.spec.ts | 14 ++-- .../tests/components.spec.tsx | 22 ++---- 15 files changed, 30 insertions(+), 169 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml index a76d46c1ce..b29ee4ec4b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml @@ -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: 236e0eedd2b3a6ba64a837fa1838d63545f13fb1 -2026-07-31-permission-default-for-new-sessions.zh.md: 8cdb5e6a0b6ca8a9a878351474728b325fb92528 +2026-07-31-permission-default-for-new-sessions.md: 78ec7a9b7c690c7b29fa10c1518fb7466971f0ac +2026-07-31-permission-default-for-new-sessions.zh.md: f6b29b879112de8b8f1c1f7ab466b7d90f9a142c diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md index 236e0eedd2..78ec7a9b7c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -14,7 +14,7 @@ The Web General-settings page displayed Permission as a disabled skeleton even t 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 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 retains only the Tool Call skeleton. +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. 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`. diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md index 8cdb5e6a0b..f6b29b8791 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -14,7 +14,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 ` 服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。 -现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。无归属的「通用」设置包只保留「工具调用」骨架。 +现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。无归属的「通用」设置包不贡献任何占位行。 ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更会发出 `host/settings-changed`,但不会发出 `host/models-changed`。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index e81b8ab6fd..e3a016dd68 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -53,8 +53,7 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) expect(await trigger.getAttribute('aria-expanded')).toBe('true') - // General is active by default; Permission, Language and Appearance are - // functional, while Tool Call remains a skeleton. + // 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 expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) @@ -88,7 +87,7 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.waitFor({ timeout: 10_000 }) const selector = dialog.getByRole('button', { name: 'Danger Full Access' }) await selector.waitFor({ timeout: 10_000 }) - expect(await selector.isEnabled()).toBe(true) + await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true) await selector.click() await page.getByRole('menuitem', { name: 'Read Only' }).click() await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index ff65e630f0..9234aa4948 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -14,7 +14,7 @@ - button "Danger Full Access": - text: Danger Full Access - img - - text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言 + - text: 语言 - button "中文": - text: 中文 - img diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 8beb4c9578..b1fd862af4 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: 9e12f02fc1e767fb807be4fdd3f506c189662bc7 -README.zh.md: 225e27f5705f33bc6199615b0fe96e04eaa6a04c +README.md: 241678567c4dbc7411ab9e76f595f2f696cc02d6 +README.zh.md: da4568d109c20bf1860fb8841942d42078b9443a diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 9e12f02fc1..241678567c 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (`settings.general.item` slot plus the Tool Call skeleton), and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages. +Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages. ## Model Experience @@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Tool Call is a display skeleton** — its backing host setting does not exist yet, so the cubes write nothing. When it gains real backing, the row moves to its owning feature plugin per the self-registration doctrine. +- The General section has no built-in rows; each row appears only when its owning feature plugin is mounted. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 225e27f570..da4568d109 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(`settings.general.item` slot 加上「工具调用」骨架行),以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 +设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 ## 模型体验 @@ -14,4 +14,4 @@ ## 已知限制与暂缓事项 -- **「工具调用」只是展示骨架**:其宿主设置尚不存在,因此控件不会写入任何内容。一旦获得实际支撑,按照自注册原则,该行会移至拥有它的功能插件。 +- 「通用」分区没有内置行;每一行仅在其所属功能插件挂载时出现。 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 6d0ee01404..af85a9954f 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", - "description": "Settings ownerless-copy plugin: the General section and Tool Call skeleton, shell trigger/header chrome content, and settings dictionaries", + "description": "Settings ownerless-copy plugin: the General section, shell trigger/header chrome content, and settings dictionaries", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index cb1d137ec8..efa367bc52 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -1,7 +1,5 @@ -/* General section rows (figma 501:29983 'Options'): stacked groups, 16px - * vertical padding each, hairline separator under all but the last child - * (feature-contributed rows carry their own row chrome and separators; the - * :last-child rule strips the trailing one wherever the column ends). */ +/* Feature-contributed rows own their chrome and separators; the section + * strips the trailing separator wherever the column ends. */ .section { display: flex; @@ -12,64 +10,3 @@ .section > :last-child { border-bottom: none; } - -/* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */ -.group { - display: flex; - flex-direction: column; - gap: 8px; - padding: 16px 0; - border-bottom: 1px solid var(--dsw-alias-border-l2); -} - -.title { - font-size: 14px; - font-weight: 400; - line-height: 22px; - color: var(--dsw-alias-label-primary); -} - -.desc { - font-size: 12px; - font-weight: 400; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - -/* Tool Call mode cubes share an 8px gap and wrap to one per row when the - panel is too narrow. */ -.cubeRow { - display: flex; - align-items: stretch; - gap: 8px; - flex-wrap: wrap; -} - -/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the - * 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10, - * vertical = inner pad 8). */ -.modeCube { - box-sizing: border-box; - flex: 1 1 276px; - display: flex; - flex-direction: column; - justify-content: center; - gap: 2px; - padding: 8px 14px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 16px; - background: transparent; - text-align: left; - cursor: pointer; -} - -.modeCube:hover:not(.selected) { - background: var(--dsw-alias-interactive-bg-hover); -} - -/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 - * step has no alias-layer name). */ -.selected { - background: var(--dsw-alias-bg-module-platform); - border-color: var(--dsw-static-neutral-bluish-400); -} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx index 861f4e23d4..1217b36f96 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.tsx +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -1,9 +1,5 @@ -/** - * The General section (figma 501:29983 'Options'): one column rendering the - * `settings.general.item` contributions. Features own their rows; this - * package contributes only the ownerless Tool Call skeleton. - */ -import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +/** The General section: one column rendering feature-owned item contributions. */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import css from './GeneralSection.module.css' /** Full component props: section owner share plus item render share. */ @@ -22,30 +18,3 @@ export function GeneralSection({ renderSlot }: GeneralSectionComponentProps) {
) } - -/** Props of the ownerless Tool Call item contribution. */ -export type ToolCallSkeletonProps = - PropsRuntime<'settings.general.item'> & PropsLocale<'settings'> - -/** - * Render the static Tool Call mode choice until its host setting exists. - * @param props - item runtime and translated copy. - * @returns the skeleton row. - */ -export function ToolCallSkeleton({ t }: ToolCallSkeletonProps) { - return ( -
-
{t('toolcall.title')}
-
-
-
{t('toolcall.schema.title')}
-
{t('toolcall.schema.desc')}
-
-
-
{t('toolcall.code.title')}
-
{t('toolcall.code.desc')}
-
-
-
- ) -} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 894b3a50e9..0a0d84ed80 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,8 +1,7 @@ /** * Settings ownerless-copy plugin, browser half: registers everything on the * Settings surface that belongs to no single feature — the trigger/header - * chrome content, the General section (`settings.general.item` slot plus the - * ownerless Tool Call skeleton), and the `settings` dictionaries. + * chrome content, the General section, and the `settings` dictionaries. * Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ @@ -13,14 +12,14 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge. import type {} from '@deepseek-ai/dsh-client-locale/client' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' -import { GeneralSection, ToolCallSkeleton } from './GeneralSection.tsx' +import { GeneralSection } from './GeneralSection.tsx' import { en, zh, type SettingsKey } from './locales.ts' export type { CloseLabelProps, HeaderContentProps, TriggerContentProps, } from './chrome.tsx' export type { - GeneralSectionComponentProps, ToolCallSkeletonProps, + GeneralSectionComponentProps, } from './GeneralSection.tsx' export type { SettingsKey } from './locales.ts' @@ -69,19 +68,11 @@ export function apply(ctx: ClientContext): void { locale: NS, children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, }, GeneralSection)) - const toolCall = deferRegistration(ctx.slots, 'settings.general.item', ToolCallSkeleton, () => - ctx.slots.register({ - name: 'settings.general.item', - id: 'tool-call', - order: -10, - locale: NS, - }, ToolCallSkeleton)) return () => { trigger.dispose() header.dispose() close.dispose() general.dispose() - toolCall.dispose() } }, 'ui-settings-general: chrome and section registrations') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index 1e3ff3b883..b71fc683b9 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -1,24 +1,11 @@ -/** - * `settings` namespace dictionaries: shell chrome plus the shell-owned - * General section (nav label and ownerless Tool Call skeleton). Technical - * mode copy is shared verbatim across locales per the Figma design. - * Feature-owned rows ship their copy in their own packages. - */ -const SHARED = { - 'toolcall.schema.title': 'Schema mode', - 'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time', - 'toolcall.code.title': 'Code mode', - 'toolcall.code.desc': 'Chain multiple tools with code — multi-step orchestration', -} satisfies Record +/** Shell chrome and General-nav dictionaries; feature rows own their copy. */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - ...SHARED, 'trigger': '设置', 'title': '设置', 'close': '关闭', 'general.nav': '通用设置', - 'toolcall.title': '工具调用', } satisfies Record /** The settings namespace key union. */ @@ -26,10 +13,8 @@ export type SettingsKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - ...SHARED, 'trigger': 'Settings', 'title': 'Settings', 'close': 'Close', 'general.nav': 'General', - 'toolcall.title': 'Tool Call', } satisfies Record diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index dfbaa49c6e..506a69699a 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -6,7 +6,7 @@ import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' -import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' /** The four seats this plugin fills (slot name → expected component). */ const SEATS = [ @@ -61,17 +61,11 @@ describe('ui-settings-general apply', () => { // The nav label is a locale-following thunk; owners resolve at read time. expect(resolveSlotLabel(entry.options.label)).toBe('通用设置') expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) - const toolEntry = before.slots.entries('settings.general.item')[0]! - expect(toolEntry).toMatchObject({ - component: ToolCallSkeleton, - options: { id: 'tool-call', order: -10 }, - }) + expect(before.slots.entries('settings.general.item')).toEqual([]) // Copy rides the standard locale seat: every seat declares the namespace. for (const [name] of SEATS) { expect(before.slots.entries(name)[0]!.locale).toBe('settings') } - expect(toolEntry.locale).toBe('settings') - const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0) @@ -83,7 +77,7 @@ describe('ui-settings-general apply', () => { expect(after.slots.entries(name)).toHaveLength(1) } await vi.waitFor(() => { - expect(after.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) + expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) }) }) @@ -133,7 +127,7 @@ describe('ui-settings-general apply', () => { for (const [name, component] of SEATS) { expect(b.slots.entries(name)[0]!.component).toBe(component) } - expect(b.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) + expect(b.slots.entries('settings.general.item')).toEqual([]) expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) // The recovered registrations still ride the locale path. b.locale.setLocale('en') diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 2538335581..db6be78ccd 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -1,18 +1,17 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' -import type { - GeneralSectionComponentProps, ToolCallSkeletonProps, -} from '../src/client/GeneralSection.tsx' -import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' +import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' +import type { TriggerContentProps } from '../src/client/chrome.tsx' import { en } from '../src/client/locales.ts' afterEach(cleanup) // The seat's key domain is settings ∪ common; the stub answers from the // package dictionary and falls back to the key like the real chain. -const t: ToolCallSkeletonProps['t'] = key => (en as Record)[key] ?? key +const t: TriggerContentProps['t'] = key => (en as Record)[key] ?? key // Global standard kit stubs: none of these components consume the hooks. const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never @@ -55,16 +54,3 @@ describe('GeneralSection', () => { expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() }) }) - -describe('ToolCallSkeleton', () => { - it('renders the mode cubes with schema pinned selected', () => { - render() - expect(screen.getByText('Tool Call')).toBeTruthy() - const schema = screen.getByText('Schema mode') - const code = screen.getByText('Code mode') - expect(schema.parentElement!.className).toContain('selected') - expect(code.parentElement!.className).not.toContain('selected') - expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy() - expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy() - }) -}) From 62ad4880206cbbad9c11bfbbc71f9e0bc244b1a5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 12:51:23 +0800 Subject: [PATCH 3/5] ci: refresh pull request mergeability From 7aa126e0d57062764e100e0cd64c5754388d5a76 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 13:36:40 +0800 Subject: [PATCH 4/5] test(session): expect empty fork seed marker --- packages/core/session/tests/fork.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index b04d08e9ab..b0381f3c3f 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -67,7 +67,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, undefined, SessionId('empty-child')) - expect(child.events).toEqual([]) + expect(inherited(child)).toEqual([]) expect(child.header).toMatchObject({ id: SessionId('empty-child'), cwd: '/workspace', From 8ab647366087b6228b39342e24f1ae8f8cc6b8f4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 13:45:22 +0800 Subject: [PATCH 5/5] test(permission): cover settings confirmation paths --- .../ui-permission/src/client/PermissionRow.tsx | 18 ++++++++---------- .../ui-permission/tests/browser-plugin.spec.ts | 2 ++ .../tests/permission-row.spec.tsx | 7 +++++++ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-permission/src/client/PermissionRow.tsx b/packages/client/ui-permission/src/client/PermissionRow.tsx index b27c5fc584..ae8c8bafe7 100644 --- a/packages/client/ui-permission/src/client/PermissionRow.tsx +++ b/packages/client/ui-permission/src/client/PermissionRow.tsx @@ -41,7 +41,7 @@ export type PermissionRowProps = export function PermissionRow({ load, select, usePermission, t }: PermissionRowProps) { const state = usePermission(snapshot => snapshot) const [open, setOpen] = useState(false) - const [confirmation, setConfirmation] = useState(null) + const [confirmingFullAccess, setConfirmingFullAccess] = useState(false) const [acknowledged, setAcknowledged] = useState(false) useEffect(() => { @@ -52,12 +52,12 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP if (state.writable && state.status !== 'unavailable') return setOpen(false) setAcknowledged(false) - setConfirmation(null) + setConfirmingFullAccess(false) }, [state.status, state.writable]) if (state.status === 'unavailable') return null const selected = state.options.find(option => option.id === state.currentValue) - const busy = state.status === 'loading' || state.status === 'saving' || confirmation !== null + const busy = state.status === 'loading' || state.status === 'saving' || confirmingFullAccess const label = selected?.label ?? (busy ? t('loading') : t('unavailable')) const description: string = state.error ?? t('description') @@ -79,7 +79,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP if (id === state.currentValue) return if (id === FULL_ACCESS_PRESET) { setAcknowledged(false) - setConfirmation(id) + setConfirmingFullAccess(true) return } void select(id) @@ -102,7 +102,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP /> { setAcknowledged(false) - setConfirmation(null) + setConfirmingFullAccess(false) }} onConfirm={() => { - if (!acknowledged || confirmation === null) return - const preset = confirmation setAcknowledged(false) - setConfirmation(null) - void select(preset) + setConfirmingFullAccess(false) + void select(FULL_ACCESS_PRESET) }} /> diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 399f5e8306..fea56a413a 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -104,6 +104,8 @@ describe('ui-permission browser plugin', () => { expect(injected?.hooks.permission).toBeDefined() expect(typeof injected?.load).toBe('function') expect(typeof injected?.select).toBe('function') + await injected!.load() + await injected!.select('read-only') }) it('availability follows the projection key; options mark the current value active and exclude custom', async () => { diff --git a/packages/client/ui-permission/tests/permission-row.spec.tsx b/packages/client/ui-permission/tests/permission-row.spec.tsx index 81a4dc4b70..f74e6ae2ad 100644 --- a/packages/client/ui-permission/tests/permission-row.spec.tsx +++ b/packages/client/ui-permission/tests/permission-row.spec.tsx @@ -75,6 +75,9 @@ describe('PermissionRow', () => { fireEvent.click(button) expect(button.getAttribute('aria-expanded')).toBe('false') fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Read Only' })) + expect(mutate).not.toHaveBeenCalled() + fireEvent.click(button) fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) await screen.findByRole('button', { name: 'Workspace Write' }) expect(mutate).toHaveBeenCalledOnce() @@ -92,6 +95,10 @@ describe('PermissionRow', () => { fireEvent.click(await screen.findByRole('button', { name: 'Read Only' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' })) expect(mutate).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog', { name: 'Enable Full access?' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Read Only' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' })) const dialog = screen.getByRole('dialog', { name: 'Enable Full access?' }) const enable = screen.getByRole('button', { name: 'Enable Full access' }) expect((enable as HTMLButtonElement).disabled).toBe(true)