From 9eb0ef4c3e40bf7d71ae907729770ddf9b2f8adb Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:50:18 -0700 Subject: [PATCH 01/13] feat(client): confirm before enabling full access --- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 24 ++++ .../src/client/contract/slots.ts | 2 + .../src/client/skeleton/InputBar.tsx | 4 +- .../skeleton/PermissionSelect.module.css | 70 ++++++++++ .../src/client/skeleton/PermissionSelect.tsx | 128 ++++++++++++++---- .../ui-conversation/tests/input-bar.spec.tsx | 97 +++++++++++-- .../tests/input-matrix.spec.tsx | 1 + .../tests/input-scenarios.spec.tsx | 1 + .../ui-conversation/tests/skeleton.spec.tsx | 1 + packages/client/ui-primitives/src/Modal.tsx | 6 +- .../client/ui-primitives/tests/atoms.spec.tsx | 3 +- 13 files changed, 289 insertions(+), 52 deletions(-) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fc466190a7..c0e04e0d12 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission ` command line through the bar's injected `command` callback. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f6fbff9c1e..193cfbe92e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c6b597ae79..8511c2c0ba 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -95,6 +95,28 @@ export function apply(ctx: Context): void { }, 'ui-conversation: command hint dictionaries') const translateHint = ctx.locale.bind(HINT_NS) + const ACCESS_NS = 'conversation.access' + 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-conversation: Access control dictionaries') + const translateAccess = ctx.locale.bind(ACCESS_NS) + // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() @@ -199,6 +221,7 @@ export function apply(ctx: Context): void { stop: undefined, command: undefined, translateHint, + translateAccess, hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON }, } } @@ -217,6 +240,7 @@ export function apply(ctx: Context): void { return result.ok && result.value.matched }, translateHint, + translateAccess, hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1684e616b2..093f10edc5 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -284,6 +284,8 @@ export interface ComposerBarInjected { command: ((line: string) => Promise) | undefined /** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */ translateHint: (key: string) => string + /** Locale-aware copy for the Access control's Full access risk confirmation. */ + translateAccess: (key: string) => string /** * Registrant hooks compartment: the renderer binds these to * useNotices/useLexicon (static absent sources without a session — hook diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 77747e0ae2..4e2b366f7f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -33,7 +33,7 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, + useSession, useInput, inputActions, keyboard, stop, command, translateHint, translateAccess, renderSlot, useNotices, useLexicon, useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment', }: InputBarProps) { @@ -272,7 +272,7 @@ export function InputBar({ // or while the command face is absent with the session). const accessSelect: ReactNode = command === undefined ? null - : + : // Mirror-layer decorations: a visible backdrop with transparent text. The // claim token highlights through behind the textarea glyphs; each U+FFFC diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index 50dce3913f..bbc51b10e9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -41,3 +41,73 @@ 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 873c8c11c4..567abaa27e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' -import { Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconWarningOutline16, Menu, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PermissionSelect.module.css' +const FULL_ACCESS = 'danger-full-access' + /** * Display transform: kebab-case machine names render as title-case labels * (`workspace-write` → `Workspace Write`); non-kebab host-configured names @@ -15,58 +17,124 @@ function displayName(name: string): string { return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') } +function optionLabel(option: PermissionSelectValue['options'][number]): string { + return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name) +} + export interface PermissionSelectProps { value: PermissionSelectValue | undefined locked: boolean command: (line: string) => Promise + t: (key: string) => string } -export function PermissionSelect({ value, locked, command }: PermissionSelectProps) { +export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) { const [pick, setPick] = useState(null) const [open, setOpen] = useState(false) + const [confirmation, setConfirmation] = useState(null) + const [acknowledged, setAcknowledged] = useState(false) if (value === undefined) return null const currentValue = pick ?? value.currentValue const current = value.options.find(option => option.value === currentValue) - const busy = pick !== null + const busy = pick !== null || confirmation !== null const items: MenuEntry[] = value.options .filter(o => o.value !== 'custom') - .map(option => ({ id: option.value, label: displayName(option.name) })) + .map(option => ({ id: option.value, label: optionLabel(option) })) - const choose = (id: string): void => { - setOpen(false) - if (id === value.currentValue) return + const submit = (id: string): void => { setPick(id) void command(`/permission ${id}`) .catch(() => false) .then(() => { setPick(null) }) } + const choose = (id: string): void => { + setOpen(false) + if (id === value.currentValue) return + if (id === FULL_ACCESS) { + setAcknowledged(false) + setConfirmation(id) + return + } + submit(id) + } + + const closeConfirmation = (): void => { + setAcknowledged(false) + setConfirmation(null) + } + + const confirmFullAccess = (): void => { + if (!acknowledged || confirmation === null) return + const id = confirmation + closeConfirmation() + submit(id) + } + return ( - { setOpen(false) }} - side="top" - anchor={ - - } - /> + <> + { setOpen(false) }} + side="top" + anchor={ + + } + /> + + + + + )} + > +
+ +

{t('confirm.description')}

+
+ +
+ ) } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c0bfda7f4a..2fa04b6af4 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -43,6 +43,8 @@ interface BenchOptions { variant?: 'hero' | 'composer' placeholder?: string translateHint?: (key: string) => string + translateAccess?: (key: string) => string + command?: (line: string) => Promise accessory?: React.ReactNode overlay?: React.ReactNode leftItems?: React.ReactNode @@ -100,12 +102,19 @@ function bench(over?: BenchOptions) { useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), stop, - command: () => Promise.resolve(true), + command: over?.command ?? (() => Promise.resolve(true)), // Mirrors the en 'command.hint' locale entries the production apply wires in. translateHint: over?.translateHint ?? ((key: string) => ({ 'placeholder.default': 'Message the agent', 'placeholder.plan': 'describe your task to generate plan', } as Record)[key] ?? key), + translateAccess: over?.translateAccess ?? ((key: string) => ({ + '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)[key] ?? key), renderSlot, variant: over?.variant ?? 'composer', ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), @@ -450,7 +459,35 @@ describe('placeholder chrome and control seats', () => { expect(view.queryByLabelText('Model')).toBeNull() }) - it('the Access chip renders the projection value and submits /permission on pick', async () => { + it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'read-only', name: 'read-only' }, + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'read-only', + } + const { view } = bench({ permissions, command }) + const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement + // Title-case display is presentation only; the menu ids stay machine names. + expect(trigger.textContent).toBe('Read Only') + fireEvent.click(trigger) + const items = view.getAllByRole('menuitem') + expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access']) + fireEvent.click(items[1]!) + // Optimistic pick + disable until admission resolves (command stub resolves true). + const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement + expect(busy.textContent).toBe('Workspace Write') + expect(busy.disabled).toBe(true) + expect(command).toHaveBeenCalledWith('/permission workspace-write') + await act(async () => {}) + expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false) + }) + + it('requires explicit risk acknowledgement before submitting Full access', async () => { + const command = vi.fn(() => Promise.resolve(true)) const permissions = { options: [ { value: 'workspace-write', name: 'workspace-write' }, @@ -458,20 +495,50 @@ describe('placeholder chrome and control seats', () => { ], currentValue: 'workspace-write', } - const { view } = bench({ permissions }) - const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement - // Title-case display is presentation only; the menu ids stay machine names. - expect(trigger.textContent).toBe('Workspace Write') - fireEvent.click(trigger) - const items = view.getAllByRole('menuitem') - expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access']) - fireEvent.click(items[1]!) - // Optimistic pick + disable until admission resolves (command stub resolves true). - const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement - expect(busy.textContent).toBe('Danger Full Access') - expect(busy.disabled).toBe(true) + const { view } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + + expect(command).not.toHaveBeenCalled() + expect(view.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy() + const enable = view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement + expect(enable.disabled).toBe(true) + + fireEvent.click(view.getByRole('checkbox', { name: 'I understand the risks and want to continue' })) + expect(enable.disabled).toBe(false) + fireEvent.click(enable) + + expect(command).toHaveBeenCalledOnce() + expect(command).toHaveBeenCalledWith('/permission danger-full-access') + expect(view.queryByRole('dialog')).toBeNull() + expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).textContent).toBe('Full access') await act(async () => {}) - expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false) + }) + + it('cancels a Full access selection without changing permission and resets acknowledgement', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view } = bench({ permissions, command }) + const openConfirmation = () => { + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + } + + openConfirmation() + fireEvent.click(view.getByRole('checkbox')) + fireEvent.click(view.getByRole('button', { name: 'Cancel' })) + expect(command).not.toHaveBeenCalled() + expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).textContent).toBe('Workspace Write') + + openConfirmation() + expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false) + expect((view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement).disabled).toBe(true) }) it('a registered entry fills its seat and receives the locked owner prop', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index a9c00b0748..7df065bb8f 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -49,6 +49,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled stop: vi.fn(), command: () => Promise.resolve(true), translateHint: (key: string) => key, + translateAccess: (key: string) => key, variant: 'composer', } return render() diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 1c7bbe50ec..5915703b06 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -135,6 +135,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { stop: vi.fn(), command: () => Promise.resolve(true), translateHint: (key: string) => key, + translateAccess: (key: string) => key, variant: 'composer', } const view = render() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 3ed459b5a9..0d60462d9b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -150,6 +150,7 @@ function mount( stop={stop} command={() => Promise.resolve(true)} translateHint={(key: string) => key} + translateAccess={(key: string) => key} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} /> diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index ef790c8b6a..f25135d1da 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -16,12 +16,13 @@ import css from './Modal.module.css' * @param props.description - optional supporting sentence under the title. * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). + * @param props.contentClassName - optional class for a scrollable content region. * @param props.headless - render children directly in the card (no default * header/close/body chrome) for dialogs whose figma frame owns its own * header structure; mask, card, Escape, and aria-label remain. * @returns null when closed; otherwise the overlay tree. */ -export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: { +export function Modal({ open, onClose, title, description, children, footer, className, contentClassName, headless = false }: { open: boolean onClose: () => void title: string @@ -29,6 +30,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla children?: ReactNode footer?: ReactNode className?: string + contentClassName?: string headless?: boolean }) { useEffect(() => { @@ -55,7 +57,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla ? children : ( <> -
+

{title}

}> + Create}> ) expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined() expect(screen.getByText('Name it.')).toBeDefined() + expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content') fireEvent.keyDown(document, { key: 'a' }) expect(onClose).not.toHaveBeenCalled() fireEvent.keyDown(document, { key: 'Escape' }) 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 02/13] 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((