From 6d416b06c59ca7435f47bfb45439f89d3ddda01b Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:48:21 -0700 Subject: [PATCH] fix(client): gate every full access picker --- apps/web/tests/access-confirmation.e2e.ts | 87 +++++++++++++ .../access-confirmation/ui.expected.md | 10 ++ .../snapshots/code-mode-round/ui.expected.md | 2 +- .../cordis-tool-round/ui.expected.md | 2 +- .../snapshots/fresh-round-trip/ui.expected.md | 2 +- .../lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/reloaded.expected.md | 2 +- .../live-interactions/cancel.expected.md | 2 +- .../live-interactions/error-auth.expected.md | 2 +- .../live-interactions/retry.expected.md | 2 +- .../snapshots/message-actions/ui.expected.md | 2 +- .../question-composer/answered.expected.md | 2 +- .../queue-actions/editing.expected.md | 2 +- .../snapshots/queue-actions/ui.expected.md | 2 +- .../snapshots/seeded-history/ui.expected.md | 2 +- .../snapshots/steering/settled.expected.md | 2 +- apps/web/tsconfig.json | 3 +- .../ui-command/src/client/PopupSelectView.tsx | 121 ++++++++++-------- .../client/ui-command/src/client/contract.ts | 11 ++ .../client/ui-command/src/client/index.ts | 2 +- .../client/ui-command/src/client/popup.ts | 53 +++++++- .../ui-command/tests/popup-view.spec.tsx | 42 ++++++ .../client/ui-command/tests/popup.spec.ts | 43 +++++++ .../skeleton/PermissionSelect.module.css | 70 ---------- .../src/client/skeleton/PermissionSelect.tsx | 63 ++++----- .../ui-conversation/tests/input-bar.spec.tsx | 36 ++++++ packages/client/ui-permission/package.json | 3 + .../client/ui-permission/src/client/index.ts | 52 +++++++- .../tests/browser-plugin.spec.ts | 20 ++- packages/client/ui-primitives/src/Modal.tsx | 10 +- .../src/RiskConfirmation.module.css | 73 +++++++++++ .../ui-primitives/src/RiskConfirmation.tsx | 80 ++++++++++++ packages/client/ui-primitives/src/index.ts | 2 + .../client/ui-primitives/tests/atoms.spec.tsx | 6 +- pnpm-lock.yaml | 3 + tsconfig.host.json | 1 + 36 files changed, 624 insertions(+), 195 deletions(-) create mode 100644 apps/web/tests/access-confirmation.e2e.ts create mode 100644 apps/web/tests/snapshots/access-confirmation/ui.expected.md create mode 100644 packages/client/ui-primitives/src/RiskConfirmation.module.css create mode 100644 packages/client/ui-primitives/src/RiskConfirmation.tsx diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts new file mode 100644 index 0000000000..e8a0875c29 --- /dev/null +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -0,0 +1,87 @@ +// Web e2e scenario: every visible permission picker gates Full access behind +// the same locale-aware, in-page risk confirmation. Zero model calls: the +// scenario boots the shipped Web composition and exercises the real +// permission projection, client command path, HTTP RPC, and pushed update. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +describe('web e2e: Full access confirmation', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // CI uses Playwright's pinned browser. A developer may point this one + // scenario at an installed Chromium when the matching browser download + // is temporarily unavailable. + const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH + browser = await chromium.launch(executablePath === undefined ? {} : { executablePath }) + // Keep the product default Chinese locale: the golden pins the actual + // registered dictionary rather than a test-local translation callback. + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('requires acknowledgement before the composer picker can enable Full access', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation')) + const access = page.locator('button[aria-label^="Access mode"]').first() + await access.waitFor({ timeout: 10_000 }) + + // Normalize the starting preset through the real command path. The + // shipped web config may already start at Full access. + if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) { + await access.click() + await page.getByRole('menuitem', { name: 'Workspace Write' }).click() + await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) + .toBe('Access mode, current: Workspace Write') + } + + await access.click() + await page.getByRole('menuitem', { name: 'Full access' }).click() + const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' }) + await dialog.waitFor({ timeout: 10_000 }) + const enable = dialog.getByRole('button', { name: '启用 Full access' }) + expect(await enable.isDisabled()).toBe(true) + + // The modal is in this page's body (not a native/new window) and escapes + // the sticky composer's stacking context. + expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + + await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check() + expect(await enable.isEnabled()).toBe(true) + await enable.click() + await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) + .toBe('Access mode, current: Full access') + expect(await dialog.count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/access-confirmation/ui.expected.md b/apps/web/tests/snapshots/access-confirmation/ui.expected.md new file mode 100644 index 0000000000..1287e6e565 --- /dev/null +++ b/apps/web/tests/snapshots/access-confirmation/ui.expected.md @@ -0,0 +1,10 @@ +- dialog "确认启用 Full access?": + - heading "确认启用 Full access?" [level=2] + - button "Close": + - img + - img + - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。 + - checkbox "我已了解风险,并愿意继续" + - text: 我已了解风险,并愿意继续 + - button "取消" + - button "启用 Full access" [disabled] diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 0282a16f80..a57dae2a6a 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -35,7 +35,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 5b51e47cf4..313ee2c08e 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -49,7 +49,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 49c7958292..9552c89d80 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -32,7 +32,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 65abda0dba..a76f170451 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -28,7 +28,7 @@ - textbox "Describe what you want to build" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 45e3514fa4..194efaa7c4 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -24,7 +24,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4323c94285..bbe2387634 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -21,7 +21,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 1d78e91c73..6812119684 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -14,7 +14,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6a9c808342..293ed5bc37 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -24,7 +24,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 19ba02d99d..7546b7b124 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -35,7 +35,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 36752c783a..583878f2be 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -32,7 +32,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 2594f18294..30cc7f4a2c 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -28,7 +28,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 919617bdab..cd8427dff2 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -22,7 +22,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index efc43a272e..3d691ea25d 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -38,7 +38,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 5efbcf385d..979f16e72b 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -32,7 +32,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7a7f228fb0..9f8c9c371c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -38,7 +38,8 @@ "tests/cordis-tool-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/queue-actions.e2e.ts", - "tests/skill-invocation-policy.e2e.ts" + "tests/skill-invocation-policy.e2e.ts", + "tests/access-confirmation.e2e.ts" ], "references": [ { diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx index ec0bbdd2bb..7e7ad91c34 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.tsx +++ b/packages/client/ui-command/src/client/PopupSelectView.tsx @@ -12,7 +12,7 @@ import { useEffect, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import { filterOptions } from './popup.ts' import type { PopupSelectController } from './popup.ts' import css from './PopupSelectView.module.css' @@ -56,23 +56,24 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { // closes the shell before its own handlers run; that click's target then // takes focus naturally, so no focusComposer here. useEffect(() => { - if (!state.open) return + if (!state.open || state.confirming !== null) return const onPointerDown = (ev: PointerEvent): void => { if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return popup.dismiss() } document.addEventListener('pointerdown', onPointerDown, true) return () => { document.removeEventListener('pointerdown', onPointerDown, true) } - }, [state.open, popup]) + }, [state.open, state.confirming, popup]) // Focus the search input after it mounts (separate effect so the ref is populated). useEffect(() => { - if (state.open) searchRef.current?.focus() - }, [state.open]) + if (state.open && state.confirming === null) searchRef.current?.focus() + }, [state.open, state.confirming]) if (!state.open) return null const rows = filterOptions(state.options, state.search) + const confirmation = state.confirming?.confirmation const onKeyDown = (ev: React.KeyboardEvent): void => { // ArrowLeft/ArrowRight fall through on purpose: the search input keeps @@ -99,55 +100,73 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { } return ( -
- { popup.setSearch(ev.currentTarget.value) }} - /> - {state.error !== null && ( -
- {state.error} - {state.status === 'failed' && ( - + <> + {state.confirming === null && ( +
+ { popup.setSearch(ev.currentTarget.value) }} + /> + {state.error !== null && ( +
+ {state.error} + {state.status === 'failed' && ( + + )} +
+ )} + {state.status === 'pending' &&
Loading options…
} + {state.submitting &&
Applying…
} + {state.status === 'ready' && rows.length === 0 &&
No options
} + {state.status === 'ready' && ( +
+ {rows.map((option, index) => ( +
{ void popup.select(index) }} + onMouseEnter={() => { popup.highlight(index) }} + > + {option.label} + {option.detail !== undefined && {option.detail}} + {option.active === true && } +
+ ))} +
)}
)} - {state.status === 'pending' &&
Loading options…
} - {state.submitting &&
Applying…
} - {state.status === 'ready' && rows.length === 0 &&
No options
} - {state.status === 'ready' && ( -
- {rows.map((option, index) => ( -
{ void popup.select(index) }} - onMouseEnter={() => { popup.highlight(index) }} - > - {option.label} - {option.detail !== undefined && {option.detail}} - {option.active === true && } -
- ))} -
+ {confirmation !== undefined && ( + { popup.acknowledge(value) }} + onCancel={() => { popup.cancelConfirmation() }} + onConfirm={() => { void popup.confirm() }} + /> )} -
+ ) } diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts index 61ab4de2e2..8498b5def8 100644 --- a/packages/client/ui-command/src/client/contract.ts +++ b/packages/client/ui-command/src/client/contract.ts @@ -6,12 +6,23 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' +/** Copy for an option that must be acknowledged before onSelect can run. */ +export interface SelectConfirmation { + readonly title: string + readonly description: string + readonly acknowledgeLabel: string + readonly cancelLabel: string + readonly confirmLabel: string +} + /** One option row of a popupSelect shell. */ export interface SelectOption { readonly id: string readonly label: string readonly detail?: string readonly active?: boolean + /** Optional in-page risk gate owned by the shared popup shell. */ + readonly confirmation?: SelectConfirmation } /** diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index f40078212e..95b3824805 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts' export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts' export type { PopupSelectInjected } from './PopupSelectView.tsx' export type { - CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption, + CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectConfirmation, SelectOption, } from './contract.ts' declare module 'cordis' { diff --git a/packages/client/ui-command/src/client/popup.ts b/packages/client/ui-command/src/client/popup.ts index c2d30f3213..5e20911820 100644 --- a/packages/client/ui-command/src/client/popup.ts +++ b/packages/client/ui-command/src/client/popup.ts @@ -67,12 +67,17 @@ export interface PopupState { readonly active: number /** A select() settlement is in flight: further select/search/highlight no-op until it settles. */ readonly submitting: boolean + /** Option waiting for explicit risk acknowledgement; null during normal selection. */ + readonly confirming: SelectOption | null + /** Caller-controlled checkbox state for the pending confirmation. */ + readonly acknowledged: boolean /** Surfaced settlement failure (options load or onSelect); null when none. */ readonly error: string | null } const CLOSED: PopupState = { - open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null, + open: false, command: null, status: 'pending', options: [], search: '', active: 0, + submitting: false, confirming: null, acknowledged: false, error: null, } /** @@ -166,7 +171,7 @@ export class PopupSelectController { */ setSearch(search: string): void { const s = this.state.getSnapshot() - if (!s.open || s.submitting || search === s.search) return + if (!s.open || s.submitting || s.confirming !== null || search === s.search) return this.state.set({ ...s, search, active: 0 }) } @@ -177,7 +182,7 @@ export class PopupSelectController { */ move(dir: 1 | -1): void { const s = this.state.getSnapshot() - if (!s.open || s.status !== 'ready' || s.submitting) return + if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return const rows = filterOptions(s.options, s.search) if (rows.length === 0) return const active = (s.active + dir + rows.length) % rows.length @@ -191,7 +196,7 @@ export class PopupSelectController { */ highlight(index: number): void { const s = this.state.getSnapshot() - if (!s.open || s.status !== 'ready' || s.submitting) return + if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return this.state.set({ ...s, active: index }) } @@ -209,10 +214,46 @@ export class PopupSelectController { async select(index: number): Promise { const binding = this.binding const s = this.state.getSnapshot() - if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return + if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return const option = filterOptions(s.options, s.search)[index] if (option === undefined) return - this.state.set({ ...s, submitting: true, error: null }) + if (option.confirmation !== undefined) { + this.state.set({ ...s, confirming: option, acknowledged: false, error: null }) + return + } + await this.settle(binding, option) + } + + /** + * Update the explicit checkbox for the currently pending risk gate. + * @param acknowledged - whether the user has acknowledged the displayed risk. + */ + acknowledge(acknowledged: boolean): void { + const s = this.state.getSnapshot() + if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return + this.state.set({ ...s, acknowledged }) + } + + /** Cancel only the risk gate and return to the still-open option picker. */ + cancelConfirmation(): void { + const s = this.state.getSnapshot() + if (!s.open || s.submitting || s.confirming === null) return + this.state.set({ ...s, confirming: null, acknowledged: false }) + } + + /** Settle the gated option only after the checkbox is acknowledged. */ + async confirm(): Promise { + const binding = this.binding + const s = this.state.getSnapshot() + if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return + await this.settle(binding, s.confirming) + } + + /** Run the business settlement for an already admitted option. */ + private async settle(binding: OpenBinding, option: SelectOption): Promise { + const s = this.state.getSnapshot() + if (this.binding !== binding || !s.open || s.submitting) return + this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null }) try { await binding.spec.onSelect(option, binding.context) } catch (error) { diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx index 2cd9891478..db2491fef0 100644 --- a/packages/client/ui-command/tests/popup-view.spec.tsx +++ b/packages/client/ui-command/tests/popup-view.spec.tsx @@ -32,6 +32,17 @@ const OPTIONS: SelectOption[] = [ { id: 'light', label: 'Light', active: true }, { id: 'sepia', label: 'Sepia', detail: 'warm' }, ] +const GATED: SelectOption = { + id: 'full', + label: 'Full access', + confirmation: { + title: 'Enable Full access?', + description: 'Sensitive operations.', + acknowledgeLabel: 'I understand the risks', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }, +} const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } @@ -143,6 +154,37 @@ describe('PopupSelectView', () => { expect(view.container.childElementCount).toBe(0) }) + it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => { + const onSelect = vi.fn() + const { popup, consume } = await mountOpen({ + options: () => Promise.resolve([GATED]), + onSelect, + }) + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + expect(screen.queryByLabelText('/theme options')).toBeNull() + expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy() + const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement + expect(enable.disabled).toBe(true) + expect(onSelect).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' })) + expect(enable.disabled).toBe(false) + await act(async () => { fireEvent.click(enable) }) + expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A') + expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('canceling a gated option returns to the picker with acknowledgement reset', async () => { + await mountOpen({ options: () => Promise.resolve([GATED]) }) + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByLabelText('/theme options')).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + expect(screen.getByRole('checkbox').checked).toBe(false) + }) + it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => { let release!: () => void const onSelect = vi.fn(() => new Promise((resolve) => { release = resolve })) diff --git a/packages/client/ui-command/tests/popup.spec.ts b/packages/client/ui-command/tests/popup.spec.ts index 87a1070a40..68e8084b80 100644 --- a/packages/client/ui-command/tests/popup.spec.ts +++ b/packages/client/ui-command/tests/popup.spec.ts @@ -19,6 +19,17 @@ const OPTIONS: SelectOption[] = [ { id: 'light', label: 'Light', active: true }, { id: 'sepia', label: 'Sepia', detail: 'warm' }, ] +const GATED: SelectOption = { + id: 'full', + label: 'Full access', + confirmation: { + title: 'Enable Full access?', + description: 'Sensitive operations.', + acknowledgeLabel: 'I understand', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }, +} const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } @@ -200,6 +211,38 @@ describe('search / move / highlight over the filtered list', () => { }) describe('select', () => { + it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => { + const onSelect = vi.fn() + const deps = makeDeps() + const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps) + await popup.select(0) + expect(popup.state.getSnapshot()).toMatchObject({ + open: true, confirming: GATED, acknowledged: false, submitting: false, + }) + expect(onSelect).not.toHaveBeenCalled() + await popup.confirm() + expect(onSelect).not.toHaveBeenCalled() + popup.acknowledge(true) + await popup.confirm() + expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A) + expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('cancels a confirmation back to the picker without selecting or consuming', async () => { + const onSelect = vi.fn() + const deps = makeDeps() + const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps) + await popup.select(0) + popup.acknowledge(true) + popup.cancelConfirmation() + expect(popup.state.getSnapshot()).toMatchObject({ + open: true, confirming: null, acknowledged: false, submitting: false, + }) + expect(onSelect).not.toHaveBeenCalled() + expect(deps.consume).not.toHaveBeenCalled() + }) + it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => { const seen: Array<{ option: SelectOption; context: Ctx }> = [] const deps = makeDeps() diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index bbc51b10e9..50dce3913f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -41,73 +41,3 @@ flex: 0 0 auto; color: var(--dsw-alias-label-caption); } - -.confirmation { - width: min(440px, 100%); - max-height: calc(100vh - 48px); - overflow: hidden; -} - -.confirmationContent { - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; -} - -@supports (height: 100dvh) { - .confirmation { - max-height: calc(100dvh - 48px); - } -} - -.warning { - display: flex; - align-items: flex-start; - gap: 10px; - color: var(--dsw-alias-label-secondary); - font-size: 14px; - line-height: 22px; -} - -.warning p { - margin: 0; -} - -.warningIcon { - flex: none; - margin-top: 2px; - color: var(--dsw-alias-state-error-primary); -} - -.acknowledgement { - display: flex; - align-items: flex-start; - gap: 10px; - margin-top: 20px; - color: var(--dsw-alias-label-primary); - font-size: 14px; - line-height: 22px; - cursor: pointer; -} - -.acknowledgement input { - flex: none; - width: 16px; - height: 16px; - margin: 3px 0 0; - accent-color: var(--dsw-alias-button-primary-fill); - cursor: pointer; -} - -.acknowledgement input:focus-visible { - outline: 2px solid var(--dsw-alias-border-l4); - outline-offset: 2px; -} - -.modalAction { - min-width: 72px; -} - -.confirmAction { - min-width: 136px; -} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 567abaa27e..d2f8cf8ec0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' -import { Button, IconWarningOutline16, Menu, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PermissionSelect.module.css' @@ -9,8 +9,9 @@ const FULL_ACCESS = 'danger-full-access' /** * Display transform: kebab-case machine names render as title-case labels * (`workspace-write` → `Workspace Write`); non-kebab host-configured names - * pass through. Twin of the /permission popup's (client ui-permission) — the - * two permission surfaces must show the same text. + * pass through. Full access intentionally overrides the machine-name + * transform so both permission surfaces use the product label `Full access`; + * the warning body remains locale-aware. */ function displayName(name: string): string { if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name @@ -34,6 +35,13 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect const [confirmation, setConfirmation] = useState(null) const [acknowledged, setAcknowledged] = useState(false) + useEffect(() => { + if (!locked && value !== undefined) return + setOpen(false) + setAcknowledged(false) + setConfirmation(null) + }, [locked, value]) + if (value === undefined) return null const currentValue = pick ?? value.currentValue @@ -68,7 +76,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect } const confirmFullAccess = (): void => { - if (!acknowledged || confirmation === null) return + if (locked || !acknowledged || confirmation === null) return const id = confirmation closeConfirmation() submit(id) @@ -99,42 +107,19 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect } /> - - - - - )} - > -
- -

{t('confirm.description')}

-
- -
+ description={t('confirm.description')} + acknowledgeLabel={t('confirm.acknowledge')} + cancelLabel={t('confirm.cancel')} + confirmLabel={t('confirm.enable')} + acknowledged={acknowledged} + disabled={locked} + onAcknowledgedChange={setAcknowledged} + onCancel={closeConfirmation} + onConfirm={confirmFullAccess} + /> ) } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 2fa04b6af4..48bfd48c53 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -541,6 +541,42 @@ describe('placeholder chrome and control seats', () => { expect((view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement).disabled).toBe(true) }) + it('revokes an open Full access confirmation when the task locks', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view, session } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + fireEvent.click(view.getByRole('checkbox')) + act(() => { session.set(snapshotOf({ removed: true })) }) + expect(view.queryByRole('dialog')).toBeNull() + expect(command).not.toHaveBeenCalled() + }) + + it('resets an open Full access confirmation when switching tasks', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view, props } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + fireEvent.click(view.getByRole('checkbox')) + view.rerender() + expect(view.queryByRole('dialog')).toBeNull() + expect(command).not.toHaveBeenCalled() + }) + it('a registered entry fills its seat and receives the locked owner prop', () => { const { view, slotCalls } = bench({ disabled: true, diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index cee54f104c..ee2d789500 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-command" ], @@ -35,6 +36,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-command": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", @@ -43,6 +45,7 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index 30fc6d2dd5..2914e96f64 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -8,15 +8,21 @@ * projection (the same host-computed select the composer chip renders); a * pick submits the `/permission ` command line, so both surfaces * write through one path and the pushed projection frame is the one - * confirmation. + * confirmation. The Full access row carries the same explicit risk gate as + * the composer chip; the shared popup shell owns the modal mechanics. */ import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' /** Required services (cordis fiber inject). */ -export const inject = ['command', 'sessions'] +export const inject = ['command', 'sessions', 'locale'] + +const FULL_ACCESS = 'danger-full-access' +const ACCESS_NS = 'permission.access' /** Read one session's current permissions projection value (undefined = capability absent). */ function selectOf(session: SessionFace | undefined): PermissionSelect | undefined { @@ -26,8 +32,9 @@ function selectOf(session: SessionFace | undefined): PermissionSelect | undefine /** * Display transform twin of the composer chip's (ui-conversation * PermissionSelect): kebab-case machine names render as title-case labels - * (`workspace-write` → `Workspace Write`) so both permission surfaces show - * the same text; non-kebab host-configured names pass through. + * (`workspace-write` → `Workspace Write`); non-kebab host-configured names + * pass through. Full access intentionally uses the product label rather than + * a title-cased machine value; its warning body remains locale-aware. */ function displayName(name: string): string { if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name @@ -35,14 +42,25 @@ function displayName(name: string): string { } /** Flatten the projection select into popup rows; `custom` is display state, never a target. */ -function optionsOf(value: PermissionSelect): SelectOption[] { +function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectOption[] { return value.options .filter(option => option.value !== 'custom') .map(option => ({ id: option.value, - label: displayName(option.name), + label: option.value === FULL_ACCESS ? 'Full access' : displayName(option.name), ...(option.description !== undefined ? { detail: option.description } : {}), ...(option.value === value.currentValue ? { active: true } : {}), + ...(option.value === FULL_ACCESS + ? { + confirmation: { + title: t('confirm.title'), + description: t('confirm.description'), + acknowledgeLabel: t('confirm.acknowledge'), + cancelLabel: t('confirm.cancel'), + confirmLabel: t('confirm.enable'), + }, + } + : {}), })) } @@ -54,6 +72,26 @@ function optionsOf(value: PermissionSelect): SelectOption[] { export function apply(ctx: ClientContext): void { const command = ctx.get('command') as CommandServiceContract const sessions = ctx.sessions + ctx.effect(() => { + const disposers = [ + ctx.locale.register(ACCESS_NS, 'zh', { + 'confirm.title': '确认启用 Full access?', + 'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', + 'confirm.acknowledge': '我已了解风险,并愿意继续', + 'confirm.cancel': '取消', + 'confirm.enable': '启用 Full access', + }), + ctx.locale.register(ACCESS_NS, 'en', { + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', + }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-permission: Full access confirmation dictionaries') + const t = ctx.locale.bind(ACCESS_NS) const sessionFor = (session: ClientSessionContext): SessionFace | undefined => sessions.binding(session.sessionId)?.session ctx.effect(() => command.decorate({ @@ -67,7 +105,7 @@ export function apply(ctx: ClientContext): void { options: (session) => { const value = selectOf(sessionFor(session)) if (value === undefined) throw new Error('permission presets are not available on this host') - return Promise.resolve(optionsOf(value)) + return Promise.resolve(optionsOf(value, t)) }, onSelect: async (option, session) => { const live = sessionFor(session) diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 5f9125db53..f8fde6d10d 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -54,6 +54,17 @@ async function bench() { ctx.provide('sessions', { binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined), }) + const en = { + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access can perform sensitive operations.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', + } as Record + ctx.provide('locale', { + register: () => () => {}, + bind: () => (key: string) => en[key] ?? key, + }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { @@ -86,7 +97,14 @@ describe('ui-permission browser plugin', () => { expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true) expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.') // Kebab-case names title-case; non-kebab host-configured names pass through. - expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Danger Full Access']) + expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access']) + expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({ + title: 'Enable Full access?', + description: 'Full access can perform sensitive operations.', + acknowledgeLabel: 'I understand the risks and want to continue', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }) b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] }) const passthrough = await c.ui.options(proj, new AbortController().signal) expect(passthrough[0]?.label).toBe('Ask Every Time') diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index f25135d1da..d4199d9581 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,9 +1,11 @@ // Modal: controlled full-viewport dialog (create-workspace and similar). -// Fixed overlay in the React tree (no react-dom portal) so ui-primitives -// stays free of a react-dom dependency; mask tokens match figma 451:18655. +// The overlay portals to this document's body so ancestor stacking contexts +// cannot leave sticky page controls above the mask. This is still an in-page +// WebUI dialog; it never creates or targets another browser/native window. import { useEffect } from 'react' import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutline16 } from './icons/index.tsx' import css from './Modal.module.css' @@ -44,7 +46,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla if (!open) return null - return ( + return createPortal((