Merge pull request #998 from deepseek-harness/feat/gui-full-access-confirmation

feat(client): confirm before enabling full access
This commit is contained in:
Ziya
2026-07-31 00:43:40 -04:00
committed by GitHub
52 changed files with 960 additions and 165 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
2026-07-31-gui-full-access-confirmation.md: 8208a20bee9b9ab8f1e73720790e3e5be4c27306
2026-07-31-gui-full-access-confirmation.zh.md: 8ac115034f562fe96d53bdba010b05648d4d5947
@@ -0,0 +1,30 @@
# Agent Note: GUI Full access risk confirmation
Status: implemented
English | [中文](2026-07-31-gui-full-access-confirmation.zh.md)
## Problem
Switching the web client to `danger-full-access` was a single click on either permission surface (the composer's Access chip and the `/permission` popup picker), with the preset shown as the title-cased machine name `Danger Full Access`. Full access reduces confirmation steps and lets the agent run sensitive operations, modify files, or execute external commands, so an accidental pick armed the most dangerous preset with no deliberate acknowledgement step.
## Decision
**Both permission surfaces gate `danger-full-access` behind one shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
- `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
- The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
- The `/permission` popup (ui-permission over the ui-command shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
- `Full access` intentionally overrides the kebab-to-title display transform on both surfaces (option rows, trigger label, settled command rows keep the machine name on the wire); the warning body remains locale-aware in Chinese and English.
## Alternatives considered
**A native/OS or separate-window confirmation.** Rejected: the dialog must stay inside the current WebUI window; a second window can appear on another display and detaches the decision from the page state it guards.
**One shared locale namespace for both surfaces' safety copy.** Rejected: the ui-permission bundle and ui-conversation load independently, so each registers the same copy under its own namespace (`permission.access` beside the conversation dictionary); the duplication is fenced with an explanatory `jscpd:ignore` block rather than a cross-bundle import.
**Gating in the host/permission backend.** Out of scope by design: the change is browser-client confirmation flow only; backend permission semantics, defaults, and the safer presets' one-click behavior are unchanged.
## Consequences
Every visible GUI path into Full access now requires a deliberate, informed acknowledgement, at the cost of one extra dialog step for users who genuinely want the preset. New pickers reuse the gate by attaching a `confirmation` payload (popup path) or the chip's state machine (composer path) instead of inventing bespoke dialogs. Acceptance: the composer flow's four gated cases in `input-bar.spec.tsx`, the popup gate in `popup-view.spec.tsx` and `popup.spec.ts`, the Modal/RiskConfirmation contract in `atoms.spec.tsx`, and the assembled `access-confirmation` web e2e whose golden pins the product-default Chinese dictionary copy.
@@ -0,0 +1,30 @@
# Agent Note: GUI Full access 风险确认
Status: implemented
[English](2026-07-31-gui-full-access-confirmation.md) | 中文
## Problem
Web 客户端切换到 `danger-full-access` 在两个权限面(编辑器的 Access chip 与 `/permission` popup 选择器)上都只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
## Decision
**两个权限面都把 `danger-full-access` 关进同一个共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不提交任何命令。**
- `RiskConfirmation`ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` 座位,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
- 编辑器 chipui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale 座位以 `access.confirm.*` 键供给。
- `/permission` popupui-permission 骑在 ui-command 外壳上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`
- `Full access` 在两个面上有意覆盖 kebab 转 Title Case 的显示变换(选项行、触发器标签;落定的命令行仍在 wire 上保留机器名);警示正文保持中英文 locale 感知。
## Alternatives considered
**原生/操作系统或独立窗口确认。** 已拒:对话框必须留在当前 WebUI 窗口内;第二个窗口可能出现在另一块显示器上,使决策脱离其守护的页面状态。
**两个面共享一个安全文案 locale namespace。** 已拒:ui-permission bundle 与 ui-conversation 可独立加载,故各自在自己的 namespace 下注册同一份文案(`permission.access` 与 conversation 词典并立);这处重复以带说明的 `jscpd:ignore` 块圈护,而非跨 bundle import。
**在 host/权限后端把关。** 设计上即出界:本变更只涉浏览器客户端确认流;后端权限语义、默认值与更安全预设的一键行为均不变。
## Consequences
进入 Full access 的每条可见 GUI 路径现在都要求刻意且知情的确认,代价是真想启用该预设的用户多一步对话框。新的选择器复用此门:popup 路径挂 `confirmation` 载荷、编辑器路径走 chip 的状态机,而不是各造对话框。验收:`input-bar.spec.tsx` 中编辑器流的四个门控用例、`popup-view.spec.tsx``popup.spec.ts` 的 popup 门、`atoms.spec.tsx` 的 Modal/RiskConfirmation 契约,以及组装态 `access-confirmation` web e2e——其 golden 钉住产品默认中文词典文案。
+104
View File
@@ -0,0 +1,104 @@
// Web e2e scenario: every visible permission picker gates Full access behind
// the same locale-aware, in-page risk confirmation. Zero model calls: the
// scenario boots the shipped Web composition and exercises the real
// permission projection, client command path, HTTP RPC, and pushed update.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
/**
* connectFreshWorkspace twin over the product default Chinese locale (the
* shared helper's anchors assume the English page every other scenario
* boots; this scenario deliberately keeps zh, so the localized picker
* copy is the anchor set).
*/
async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise<void> {
await page.getByRole('button', { name: '选择工作区' }).click()
await page.getByRole('menuitem', { name: '新建工作区' }).click()
const dialog = page.getByRole('dialog', { name: '新建工作区' })
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByLabel('新工作区名称').fill(name)
await dialog.getByRole('button', { name: '创建工作区' }).click()
await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
.waitFor({ timeout: 15_000 })
}
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
describe('web e2e: Full access confirmation', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
// CI uses Playwright's pinned browser. A developer may point this one
// scenario at an installed Chromium when the matching browser download
// is temporarily unavailable.
const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH
browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
// Keep the product default Chinese locale: the golden pins the actual
// registered dictionary rather than a test-local translation callback.
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspaceZh(page)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('requires acknowledgement before the composer picker can enable Full access', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation'))
const access = page.locator('button[aria-label^="访问模式"]').first()
await access.waitFor({ timeout: 10_000 })
// Normalize the starting preset through the real command path. The
// shipped web config may already start at Full access.
if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
await access.click()
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
.toBe('访问模式,当前:Workspace Write')
}
await access.click()
await page.getByRole('menuitem', { name: 'Full access' }).click()
const dialog = page.getByRole('dialog', { name: '确认启用 Full access' })
await dialog.waitFor({ timeout: 10_000 })
const enable = dialog.getByRole('button', { name: '启用 Full access' })
expect(await enable.isDisabled()).toBe(true)
// The modal is in this page's body (not a native/new window) and escapes
// the sticky composer's stacking context.
expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true)
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
expect(await enable.isEnabled()).toBe(true)
await enable.click()
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
.toBe('访问模式,当前:Full access')
expect(await dialog.count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps its snapshot inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})
+1 -1
View File
@@ -238,7 +238,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
// where neither half repeats the other (the dispatched `/` and its
// argument stay out of the title, and the settlement text never restates
// the command's own name).
await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click()
await page.getByRole('button', { name: 'Access mode, current: Full access' }).click()
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 })
// Scoped to the row itself, so unrelated page text that happens to read
@@ -0,0 +1,10 @@
- dialog "确认启用 Full access":
- heading "确认启用 Full access" [level=2]
- button "Close":
- img
- img
- paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
- checkbox "我已了解风险,并愿意继续"
- text: 我已了解风险,并愿意继续
- button "取消"
- button "启用 Full access" [disabled]
@@ -37,7 +37,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -52,7 +52,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -32,7 +32,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -28,7 +28,7 @@
- textbox "Describe what you want to build"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -28,7 +28,7 @@
- textbox "Describe what you want to build"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Plan mode on, press to turn off": Plan
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
@@ -24,7 +24,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -21,7 +21,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -14,7 +14,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -26,7 +26,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -39,7 +39,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
@@ -37,7 +37,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -32,7 +32,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -16,7 +16,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -29,7 +29,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -22,7 +22,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -42,7 +42,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
@@ -33,7 +33,7 @@
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
+2 -1
View File
@@ -43,7 +43,8 @@
"tests/cordis-tool-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/queue-actions.e2e.ts",
"tests/skill-invocation-policy.e2e.ts"
"tests/skill-invocation-policy.e2e.ts",
"tests/access-confirmation.e2e.ts"
],
"references": [
{
+2 -1
View File
@@ -822,6 +822,7 @@ flowchart TD
pkg_tool_ask_user --> pkg_invariants
pkg_tool_ask_user --> pkg_tools
pkg_tool_ask_user --> pkg_user_interaction
pkg_client_ui_permission --> pkg_client_locale
pkg_client_ui_permission --> pkg_client_runtime
pkg_client_ui_permission --> pkg_client_ui_command
pkg_client_ui_permission --> pkg_client_ui_slash
@@ -1174,7 +1175,7 @@ flowchart TD
| [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
@@ -12,7 +12,7 @@
import { useEffect, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { filterOptions } from './popup.ts'
import type { PopupSelectController } from './popup.ts'
@@ -60,23 +60,24 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
// closes the shell before its own handlers run; that click's target then
// takes focus naturally, so no focusComposer here.
useEffect(() => {
if (!state.open) return
if (!state.open || state.confirming !== null) return
const onPointerDown = (ev: PointerEvent): void => {
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
popup.dismiss()
}
document.addEventListener('pointerdown', onPointerDown, true)
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
}, [state.open, popup])
}, [state.open, state.confirming, popup])
// Focus the search input after it mounts (separate effect so the ref is populated).
useEffect(() => {
if (state.open) searchRef.current?.focus()
}, [state.open])
if (state.open && state.confirming === null) searchRef.current?.focus()
}, [state.open, state.confirming])
if (!state.open) return null
const rows = filterOptions(state.options, state.search)
const confirmation = state.confirming?.confirmation
const onKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>): void => {
// ArrowLeft/ArrowRight fall through on purpose: the search input keeps
@@ -103,55 +104,73 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
}
return (
<div
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={t('overlay.aria', { command: String(state.command) })}
onKeyDown={onKeyDown}
>
<input
ref={searchRef}
className={css.search}
type="text"
placeholder={t('search.placeholder')}
aria-label={t('search.aria')}
value={state.search}
readOnly={state.submitting}
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
/>
{state.error !== null && (
<div className={css.error} role="alert">
<span className={css.errorText}>{state.error}</span>
{state.status === 'failed' && (
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>{t('retry')}</button>
<>
{state.confirming === null && (
<div
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={t('overlay.aria', { command: String(state.command) })}
onKeyDown={onKeyDown}
>
<input
ref={searchRef}
className={css.search}
type="text"
placeholder={t('search.placeholder')}
aria-label={t('search.aria')}
value={state.search}
readOnly={state.submitting}
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
/>
{state.error !== null && (
<div className={css.error} role="alert">
<span className={css.errorText}>{state.error}</span>
{state.status === 'failed' && (
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>{t('retry')}</button>
)}
</div>
)}
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
{rows.map((option, index) => (
<div
key={option.id}
role="option"
aria-selected={index === state.active}
className={clsx(css.row, index === state.active && css.rowActive)}
// mousedown would race the document capture listener; the shell
// owns focus anyway, so a plain click (inside the card → no
// dismiss) works.
onClick={() => { void popup.select(index) }}
onMouseEnter={() => { popup.highlight(index) }}
>
<span className={css.label}>{option.label}</span>
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
</div>
))}
</div>
)}
</div>
)}
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
{rows.map((option, index) => (
<div
key={option.id}
role="option"
aria-selected={index === state.active}
className={clsx(css.row, index === state.active && css.rowActive)}
// mousedown would race the document capture listener; the shell
// owns focus anyway, so a plain click (inside the card → no
// dismiss) works.
onClick={() => { void popup.select(index) }}
onMouseEnter={() => { popup.highlight(index) }}
>
<span className={css.label}>{option.label}</span>
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
</div>
))}
</div>
{confirmation !== undefined && (
<RiskConfirmation
open
title={confirmation.title}
description={confirmation.description}
acknowledgeLabel={confirmation.acknowledgeLabel}
cancelLabel={confirmation.cancelLabel}
confirmLabel={confirmation.confirmLabel}
acknowledged={state.acknowledged}
onAcknowledgedChange={(value) => { popup.acknowledge(value) }}
onCancel={() => { popup.cancelConfirmation() }}
onConfirm={() => { void popup.confirm() }}
/>
)}
</div>
</>
)
}
@@ -6,12 +6,23 @@
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
/** Copy for an option that must be acknowledged before onSelect can run. */
export interface SelectConfirmation {
readonly title: string
readonly description: string
readonly acknowledgeLabel: string
readonly cancelLabel: string
readonly confirmLabel: string
}
/** One option row of a popupSelect shell. */
export interface SelectOption {
readonly id: string
readonly label: string
readonly detail?: string
readonly active?: boolean
/** Optional in-page risk gate owned by the shared popup shell. */
readonly confirmation?: SelectConfirmation
}
/**
@@ -24,7 +24,7 @@ export { filterOptions, PopupSelectController } from './popup.ts'
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
export type {
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectConfirmation, SelectOption,
} from './contract.ts'
export type { CommandKey } from './locales.ts'
+47 -6
View File
@@ -67,12 +67,17 @@ export interface PopupState {
readonly active: number
/** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
readonly submitting: boolean
/** Option waiting for explicit risk acknowledgement; null during normal selection. */
readonly confirming: SelectOption | null
/** Caller-controlled checkbox state for the pending confirmation. */
readonly acknowledged: boolean
/** Surfaced settlement failure (options load or onSelect); null when none. */
readonly error: string | null
}
const CLOSED: PopupState = {
open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null,
open: false, command: null, status: 'pending', options: [], search: '', active: 0,
submitting: false, confirming: null, acknowledged: false, error: null,
}
/**
@@ -166,7 +171,7 @@ export class PopupSelectController<TCtx = unknown> {
*/
setSearch(search: string): void {
const s = this.state.getSnapshot()
if (!s.open || s.submitting || search === s.search) return
if (!s.open || s.submitting || s.confirming !== null || search === s.search) return
this.state.set({ ...s, search, active: 0 })
}
@@ -177,7 +182,7 @@ export class PopupSelectController<TCtx = unknown> {
*/
move(dir: 1 | -1): void {
const s = this.state.getSnapshot()
if (!s.open || s.status !== 'ready' || s.submitting) return
if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
const rows = filterOptions(s.options, s.search)
if (rows.length === 0) return
const active = (s.active + dir + rows.length) % rows.length
@@ -191,7 +196,7 @@ export class PopupSelectController<TCtx = unknown> {
*/
highlight(index: number): void {
const s = this.state.getSnapshot()
if (!s.open || s.status !== 'ready' || s.submitting) return
if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return
this.state.set({ ...s, active: index })
}
@@ -209,10 +214,46 @@ export class PopupSelectController<TCtx = unknown> {
async select(index: number): Promise<void> {
const binding = this.binding
const s = this.state.getSnapshot()
if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return
if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
const option = filterOptions(s.options, s.search)[index]
if (option === undefined) return
this.state.set({ ...s, submitting: true, error: null })
if (option.confirmation !== undefined) {
this.state.set({ ...s, confirming: option, acknowledged: false, error: null })
return
}
await this.settle(binding, option)
}
/**
* Update the explicit checkbox for the currently pending risk gate.
* @param acknowledged - whether the user has acknowledged the displayed risk.
*/
acknowledge(acknowledged: boolean): void {
const s = this.state.getSnapshot()
if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return
this.state.set({ ...s, acknowledged })
}
/** Cancel only the risk gate and return to the still-open option picker. */
cancelConfirmation(): void {
const s = this.state.getSnapshot()
if (!s.open || s.submitting || s.confirming === null) return
this.state.set({ ...s, confirming: null, acknowledged: false })
}
/** Settle the gated option only after the checkbox is acknowledged. */
async confirm(): Promise<void> {
const binding = this.binding
const s = this.state.getSnapshot()
if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return
await this.settle(binding, s.confirming)
}
/** Run the business settlement for an already admitted option. */
private async settle(binding: OpenBinding<TCtx>, option: SelectOption): Promise<void> {
const s = this.state.getSnapshot()
if (this.binding !== binding || !s.open || s.submitting) return
this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null })
try {
await binding.spec.onSelect(option, binding.context)
} catch (error) {
@@ -38,6 +38,17 @@ const OPTIONS: SelectOption[] = [
{ id: 'light', label: 'Light', active: true },
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
]
const GATED: SelectOption = {
id: 'full',
label: 'Full access',
confirmation: {
title: 'Enable Full access?',
description: 'Sensitive operations.',
acknowledgeLabel: 'I understand the risks',
cancelLabel: 'Cancel',
confirmLabel: 'Enable Full access',
},
}
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
@@ -149,6 +160,37 @@ describe('PopupSelectView', () => {
expect(view.container.childElementCount).toBe(0)
})
it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => {
const onSelect = vi.fn()
const { popup, consume } = await mountOpen({
options: () => Promise.resolve([GATED]),
onSelect,
})
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
expect(screen.queryByLabelText('/theme 选项')).toBeNull()
expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy()
const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement
expect(enable.disabled).toBe(true)
expect(onSelect).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' }))
expect(enable.disabled).toBe(false)
await act(async () => { fireEvent.click(enable) })
expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A')
expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
expect(popup.state.getSnapshot().open).toBe(false)
})
it('canceling a gated option returns to the picker with acknowledgement reset', async () => {
await mountOpen({ options: () => Promise.resolve([GATED]) })
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
fireEvent.click(screen.getByRole('checkbox'))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByLabelText('/theme 选项')).toBeTruthy()
await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
expect(screen.getByRole<HTMLInputElement>('checkbox').checked).toBe(false)
})
it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => {
let release!: () => void
const onSelect = vi.fn(() => new Promise<void>((resolve) => { release = resolve }))
@@ -19,6 +19,17 @@ const OPTIONS: SelectOption[] = [
{ id: 'light', label: 'Light', active: true },
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
]
const GATED: SelectOption = {
id: 'full',
label: 'Full access',
confirmation: {
title: 'Enable Full access?',
description: 'Sensitive operations.',
acknowledgeLabel: 'I understand',
cancelLabel: 'Cancel',
confirmLabel: 'Enable Full access',
},
}
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
@@ -200,6 +211,38 @@ describe('search / move / highlight over the filtered list', () => {
})
describe('select', () => {
it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => {
const onSelect = vi.fn()
const deps = makeDeps()
const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
await popup.select(0)
expect(popup.state.getSnapshot()).toMatchObject({
open: true, confirming: GATED, acknowledged: false, submitting: false,
})
expect(onSelect).not.toHaveBeenCalled()
await popup.confirm()
expect(onSelect).not.toHaveBeenCalled()
popup.acknowledge(true)
await popup.confirm()
expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A)
expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
expect(popup.state.getSnapshot().open).toBe(false)
})
it('cancels a confirmation back to the picker without selecting or consuming', async () => {
const onSelect = vi.fn()
const deps = makeDeps()
const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
await popup.select(0)
popup.acknowledge(true)
popup.cancelConfirmation()
expect(popup.state.getSnapshot()).toMatchObject({
open: true, confirming: null, acknowledged: false, submitting: false,
})
expect(onSelect).not.toHaveBeenCalled()
expect(deps.consume).not.toHaveBeenCalled()
})
it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => {
const seen: Array<{ option: SelectOption; context: Ctx }> = []
const deps = makeDeps()
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 2f3e545bfd7d29dbdbb0e23d833c2c19ee685a9d
README.zh.md: 12c043f78a242730a6f1e622df997ec5cbacc8fd
README.md: 3e0bca6610e5503e4c2c1f9fe5ad7a07bbcbdd2a
README.zh.md: 1606461fc1060825133bb0c3f3f6381bda3926ad
+1 -1
View File
@@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission <preset>` command line through the bar's injected `command` callback.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
+1 -1
View File
@@ -20,7 +20,7 @@
工具行同样是 slot:独立工具环(`ToolViewRegistry``ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
@@ -23,6 +23,11 @@ export const zh = {
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'access.confirm.title': '确认启用 Full access',
'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用 Full access',
'hero.headline': '开始构建吧',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
@@ -112,6 +117,11 @@ export const en = {
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'access.confirm.title': 'Enable Full access?',
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'access.confirm.acknowledge': 'I understand the risks and want to continue',
'access.confirm.cancel': 'Cancel',
'access.confirm.enable': 'Enable Full access',
'hero.headline': 'Let\'s start building',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
@@ -279,7 +279,7 @@ export function InputBar({
// or while the command face is absent with the session).
const accessSelect: ReactNode = command === undefined
? null
: <PermissionSelect value={permissions} locked={locked} command={command} t={t} />
: <PermissionSelect key={sessionId} value={permissions} locked={locked} command={command} t={t} />
// Mirror-layer decorations: a visible backdrop with transparent text. The
// claim token highlights through behind the textarea glyphs; each U+FFFC
@@ -1,21 +1,28 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import css from './PermissionSelect.module.css'
const FULL_ACCESS = 'danger-full-access'
/**
* Display transform: kebab-case machine names render as title-case labels
* (`workspace-write` → `Workspace Write`); non-kebab host-configured names
* pass through. Twin of the /permission popup's (client ui-permission) — the
* two permission surfaces must show the same text.
* pass through. Full access intentionally overrides the machine-name
* transform so both permission surfaces use the product label `Full access`;
* the warning body remains locale-aware.
*/
function displayName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function optionLabel(option: PermissionSelectValue['options'][number]): string {
return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name)
}
export interface PermissionSelectProps {
value: PermissionSelectValue | undefined
locked: boolean
@@ -27,49 +34,94 @@ export interface PermissionSelectProps {
export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) {
const [pick, setPick] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [confirmation, setConfirmation] = useState<string | null>(null)
const [acknowledged, setAcknowledged] = useState(false)
useEffect(() => {
if (!locked && value !== undefined) return
setOpen(false)
setAcknowledged(false)
setConfirmation(null)
}, [locked, value])
if (value === undefined) return null
const currentValue = pick ?? value.currentValue
const current = value.options.find(option => option.value === currentValue)
const busy = pick !== null
const busy = pick !== null || confirmation !== null
const items: MenuEntry[] = value.options
.filter(o => o.value !== 'custom')
.map(option => ({ id: option.value, label: displayName(option.name) }))
.map(option => ({ id: option.value, label: optionLabel(option) }))
const choose = (id: string): void => {
setOpen(false)
if (id === value.currentValue) return
const submit = (id: string): void => {
setPick(id)
void command(`/permission ${id}`)
.catch(() => false)
.then(() => { setPick(null) })
}
const choose = (id: string): void => {
setOpen(false)
if (id === value.currentValue) return
if (id === FULL_ACCESS) {
setAcknowledged(false)
setConfirmation(id)
return
}
submit(id)
}
const closeConfirmation = (): void => {
setAcknowledged(false)
setConfirmation(null)
}
const confirmFullAccess = (): void => {
if (locked || !acknowledged || confirmation === null) return
const id = confirmation
closeConfirmation()
submit(id)
}
return (
<Menu
open={open}
items={items}
selectedId={currentValue}
onSelect={choose}
onClose={() => { setOpen(false) }}
side="top"
anchor={
<button
type="button"
className={css.trigger}
aria-label={t('input.accessMode', { name: displayName(current?.name ?? currentValue) })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
>
<span className={css.triggerLabel}>{displayName(current?.name ?? currentValue)}</span>
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</button>
}
/>
<>
<Menu
open={open}
items={items}
selectedId={currentValue}
onSelect={choose}
onClose={() => { setOpen(false) }}
side="top"
anchor={
<button
type="button"
className={css.trigger}
aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current) })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
>
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</button>
}
/>
<RiskConfirmation
open={confirmation !== null}
title={t('access.confirm.title')}
description={t('access.confirm.description')}
acknowledgeLabel={t('access.confirm.acknowledge')}
cancelLabel={t('access.confirm.cancel')}
confirmLabel={t('access.confirm.enable')}
acknowledged={acknowledged}
disabled={locked}
onAcknowledgedChange={setAcknowledged}
onCancel={closeConfirmation}
onConfirm={confirmFullAccess}
/>
</>
)
}
@@ -46,6 +46,7 @@ interface BenchOptions {
variant?: 'hero' | 'composer'
placeholder?: string
t?: InputBarProps['t']
command?: (line: string) => Promise<boolean>
accessory?: React.ReactNode
overlay?: React.ReactNode
leftItems?: React.ReactNode
@@ -108,7 +109,7 @@ function bench(over?: BenchOptions) {
useLexicon: bindSnapshotSelector(shell.lexicon),
useMenuLauncher: bindSnapshotSelector(menuLauncher),
stop,
command: () => Promise.resolve(true),
command: over?.command ?? (() => Promise.resolve(true)),
// Mirrors the real lookup chain (conversation namespace, then common).
t: over?.t ?? makeTranslate(zh, commonZh),
renderSlot,
@@ -467,7 +468,35 @@ describe('command launcher chrome and control seats', () => {
expect(launcher.getAttribute('aria-expanded')).toBe('true')
})
it('the Access chip renders the projection value and submits /permission on pick', async () => {
it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'read-only', name: 'read-only' },
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'read-only',
}
const { view } = bench({ permissions, command })
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Read Only')
fireEvent.click(trigger)
const items = view.getAllByRole('menuitem')
expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
expect(busy.textContent).toBe('Workspace Write')
expect(busy.disabled).toBe(true)
expect(command).toHaveBeenCalledWith('/permission workspace-write')
await act(async () => {})
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
})
it('requires explicit risk acknowledgement before submitting Full access', async () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'workspace-write', name: 'workspace-write' },
@@ -475,20 +504,86 @@ describe('command launcher chrome and control seats', () => {
],
currentValue: 'workspace-write',
}
const { view } = bench({ permissions })
const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
// Title-case display is presentation only; the menu ids stay machine names.
expect(trigger.textContent).toBe('Workspace Write')
fireEvent.click(trigger)
const items = view.getAllByRole('menuitem')
expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access'])
fireEvent.click(items[1]!)
// Optimistic pick + disable until admission resolves (command stub resolves true).
const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
expect(busy.textContent).toBe('Danger Full Access')
expect(busy.disabled).toBe(true)
const { view } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
expect(command).not.toHaveBeenCalled()
expect(view.getByRole('dialog', { name: '确认启用 Full access' })).toBeTruthy()
const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement
expect(enable.disabled).toBe(true)
fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
expect(enable.disabled).toBe(false)
fireEvent.click(enable)
expect(command).toHaveBeenCalledOnce()
expect(command).toHaveBeenCalledWith('/permission danger-full-access')
expect(view.queryByRole('dialog')).toBeNull()
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access')
await act(async () => {})
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
})
it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'workspace-write',
}
const { view } = bench({ permissions, command })
const openConfirmation = () => {
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
}
openConfirmation()
fireEvent.click(view.getByRole('checkbox'))
fireEvent.click(view.getByRole('button', { name: '取消' }))
expect(command).not.toHaveBeenCalled()
expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write')
openConfirmation()
expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true)
})
it('revokes an open Full access confirmation when the task locks', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'workspace-write',
}
const { view, session } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('checkbox'))
act(() => { session.set(snapshotOf({ removed: true })) })
expect(view.queryByRole('dialog')).toBeNull()
expect(command).not.toHaveBeenCalled()
})
it('resets an open Full access confirmation when switching tasks', () => {
const command = vi.fn(() => Promise.resolve(true))
const permissions = {
options: [
{ value: 'workspace-write', name: 'workspace-write' },
{ value: 'danger-full-access', name: 'danger-full-access' },
],
currentValue: 'workspace-write',
}
const { view, props } = bench({ permissions, command })
fireEvent.click(view.getByLabelText(/^访问模式/))
fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
fireEvent.click(view.getByRole('checkbox'))
view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
expect(view.queryByRole('dialog')).toBeNull()
expect(command).not.toHaveBeenCalled()
})
it('a registered entry fills its seat and receives the locked owner prop', () => {
@@ -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:^",
@@ -8,15 +8,21 @@
* projection (the same host-computed select the composer chip renders); a
* pick submits the `/permission <preset>` command line, so both surfaces
* write through one path and the pushed projection frame is the one
* confirmation.
* 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,30 @@ function optionsOf(value: PermissionSelect): SelectOption[] {
export function apply(ctx: ClientContext): void {
const command = ctx.get('command') as CommandServiceContract
const sessions = ctx.sessions
// This optional bundle and ui-conversation can load independently, so each
// owns the same safety copy under its own locale namespace.
/* jscpd:ignore-start */
ctx.effect(() => {
const disposers = [
ctx.locale.register(ACCESS_NS, 'zh', {
'confirm.title': '确认启用 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')
/* jscpd:ignore-end */
const t = ctx.locale.bind(ACCESS_NS)
const sessionFor = (session: ClientSessionContext): SessionFace | undefined =>
sessions.binding(session.sessionId)?.session
ctx.effect(() => command.decorate({
@@ -67,7 +109,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)
@@ -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<string, string>
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')
+10 -6
View File
@@ -1,9 +1,11 @@
// Modal: controlled full-viewport dialog (create-workspace and similar).
// Fixed overlay in the React tree (no react-dom portal) so ui-primitives
// stays free of a react-dom dependency; mask tokens match figma 451:18655.
// The overlay portals to this document's body so ancestor stacking contexts
// cannot leave sticky page controls above the mask. This is still an in-page
// WebUI dialog; it never creates or targets another browser/native window.
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { IconCloseOutline16 } from './icons/index.tsx'
import css from './Modal.module.css'
@@ -17,6 +19,7 @@ import css from './Modal.module.css'
* @param props.description - optional supporting sentence under the title.
* @param props.children - body (inputs, etc.).
* @param props.footer - action row (Cancel / Create).
* @param props.contentClassName - optional class for a scrollable content region.
* @param props.headless - render children directly in the card (no default
* header/close/body chrome) for dialogs whose figma frame owns its own
* header structure; mask, card, Escape, and aria-label remain.
@@ -25,7 +28,7 @@ import css from './Modal.module.css'
* @returns null when closed; otherwise the overlay tree.
*/
export function Modal({
open, onClose, title, closeLabel = 'Close', description, children, footer, className, headless = false,
open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false,
}: {
open: boolean
onClose: () => void
@@ -35,6 +38,7 @@ export function Modal({
children?: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
headless?: boolean
}) {
useEffect(() => {
@@ -48,7 +52,7 @@ export function Modal({
if (!open) return null
return (
return createPortal((
<div className={css.root} role="presentation">
<div className={css.mask} aria-hidden="true" onClick={onClose} />
<div
@@ -61,7 +65,7 @@ export function Modal({
? children
: (
<>
<div className={css.content}>
<div className={clsx(css.content, contentClassName)}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
@@ -78,5 +82,5 @@ export function Modal({
)}
</div>
</div>
)
), document.body)
}
@@ -0,0 +1,73 @@
.confirmation {
width: min(440px, 100%);
max-height: calc(100vh - 48px);
overflow: hidden;
}
.confirmationContent {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
@supports (height: 100dvh) {
.confirmation {
max-height: calc(100dvh - 48px);
}
}
.warning {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 22px;
}
.warning p {
margin: 0;
}
.warningIcon {
flex: none;
margin-top: 2px;
color: var(--dsw-alias-state-error-primary);
}
.acknowledgement {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 20px;
color: var(--dsw-alias-label-primary);
font-size: 14px;
line-height: 22px;
cursor: pointer;
}
.acknowledgement input {
flex: none;
width: 16px;
height: 16px;
margin: 3px 0 0;
accent-color: var(--dsw-alias-button-primary-fill);
cursor: pointer;
}
.acknowledgement input:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
.acknowledgement input:disabled {
cursor: default;
}
.modalAction {
min-width: 72px;
}
.confirmAction {
min-width: 136px;
}
@@ -0,0 +1,80 @@
/**
* Controlled risk acknowledgement dialog shared by product surfaces that
* must gate a sensitive action behind an explicit checkbox.
*/
import { Button } from './Button.tsx'
import { IconWarningOutline16 } from './icons/index.tsx'
import { Modal } from './Modal.tsx'
import css from './RiskConfirmation.module.css'
export interface RiskConfirmationProps {
open: boolean
title: string
description: string
acknowledgeLabel: string
cancelLabel: string
confirmLabel: string
acknowledged: boolean
disabled?: boolean
onAcknowledgedChange: (acknowledged: boolean) => void
onCancel: () => void
onConfirm: () => void
}
/**
* Render one in-page confirmation whose primary action is unavailable until
* the caller-controlled acknowledgement is checked.
*/
export function RiskConfirmation({
open,
title,
description,
acknowledgeLabel,
cancelLabel,
confirmLabel,
acknowledged,
disabled = false,
onAcknowledgedChange,
onCancel,
onConfirm,
}: RiskConfirmationProps) {
return (
<Modal
open={open}
onClose={onCancel}
title={title}
className={css.confirmation ?? ''}
contentClassName={css.confirmationContent ?? ''}
footer={(
<>
<Button variant="outline" className={css.modalAction} onClick={onCancel}>
{cancelLabel}
</Button>
<Button
variant="primary"
className={css.confirmAction}
disabled={disabled || !acknowledged}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</>
)}
>
<div className={css.warning}>
<IconWarningOutline16 size={18} className={css.warningIcon} />
<p>{description}</p>
</div>
<label className={css.acknowledgement}>
<input
type="checkbox"
checked={acknowledged}
disabled={disabled}
autoFocus
onChange={(event) => { onAcknowledgedChange(event.currentTarget.checked) }}
/>
<span>{acknowledgeLabel}</span>
</label>
</Modal>
)
}
@@ -13,6 +13,8 @@ export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
export { HoverCard } from './HoverCard.tsx'
export { Modal } from './Modal.tsx'
export { RiskConfirmation } from './RiskConfirmation.tsx'
export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'
export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
@@ -324,12 +324,17 @@ describe('Modal', () => {
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
expect(screen.queryByRole('dialog')).toBeNull()
rerender(
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." footer={<button type="button">Create</button>}>
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." contentClassName="scrolling-content" footer={<button type="button">Create</button>}>
<input aria-label="name" />
</Modal>)
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
const dialog = screen.getByRole('dialog', { name: 'Create new workspace' })
expect(dialog).toBeDefined()
// The full-page layer escapes caller stacking contexts but remains in
// this document/current WebUI window.
expect(dialog.parentElement?.parentElement).toBe(document.body)
expect(screen.getByRole('button', { name: 'Configure later' })).toBeDefined()
expect(screen.getByText('Name it.')).toBeDefined()
expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content')
fireEvent.keyDown(document, { key: 'a' })
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(document, { key: 'Escape' })
@@ -847,18 +847,7 @@ describe('workspace context request injection', () => {
it('mounts without requiring a filesystem provider', async () => {
const ctx = new Context()
try {
const outcome = await Promise.race([
ctx.plugin(workspaceContext, { maxBytes: 65536 }).then(() => {
return 'settled' as const
}),
new Promise<'pending'>((resolve) => {
setTimeout(() => {
resolve('pending')
}, 50)
}),
])
expect(outcome).toBe('settled')
await ctx.plugin(workspaceContext, { maxBytes: 65536 })
} finally {
await ctx.fiber.dispose()
}
+3
View File
@@ -1413,6 +1413,9 @@ importers:
packages/client/ui-permission:
devDependencies:
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
+68 -5
View File
@@ -1,6 +1,17 @@
#!/usr/bin/env node
import { randomUUID } from 'node:crypto'
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import {
closeSync,
existsSync,
fstatSync,
lstatSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import { spawnSync } from 'node:child_process'
import { dirname, isAbsolute, join, resolve } from 'node:path'
@@ -11,6 +22,7 @@ const OWNERSHIP_MARKER_VERSION = 1
const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks'
const INSTALL_LOCK = 'dsh-lefthook-install.lock'
const INSTALL_LOCK_TIMEOUT_MS = 30_000
const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 1_000
const INSTALL_LOCK_POLL_MS = 50
const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
@@ -303,6 +315,11 @@ function parseInstallLock(record) {
return Number.isSafeInteger(owner) ? owner : undefined
}
function installLockRecordMayBeIncomplete(record) {
// Exclusive creation exposes the inode before its owner record is fully written.
return record === '' || (!record.endsWith('\n') && /^[1-9]\d*(?: [0-9a-f-]*)?$/i.test(record))
}
function lockOwnerIsAlive(owner) {
try {
process.kill(owner, 0)
@@ -351,11 +368,29 @@ async function acquireInstallLock(commonDirectory) {
const lockPath = join(commonDirectory, INSTALL_LOCK)
const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
const ownedRecord = `${String(process.pid)} ${randomUUID()}\n`
let initializingLock
while (true) {
try {
writeFileSync(lockPath, ownedRecord, { flag: 'wx', mode: 0o600 })
const ownedStat = installLockStat(lockPath)
if (ownedStat === undefined || !ownedStat.isFile() || ownedStat.isSymbolicLink()) {
const lockHandle = openSync(lockPath, 'wx', 0o600)
let ownedStat
try {
ownedStat = fstatSync(lockHandle)
const writeDelay = Number(process.env.DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS ?? 0)
if (writeDelay > 0) {
await new Promise(resolveWait => setTimeout(resolveWait, writeDelay))
}
writeFileSync(lockHandle, ownedRecord)
} finally {
closeSync(lockHandle)
}
const publishedStat = installLockStat(lockPath)
if (
publishedStat === undefined
|| !publishedStat.isFile()
|| publishedStat.isSymbolicLink()
|| publishedStat.dev !== ownedStat.dev
|| publishedStat.ino !== ownedStat.ino
) {
throw lockOwnershipChangedError(lockPath)
}
return () => releaseInstallLock(lockPath, ownedRecord, ownedStat)
@@ -368,8 +403,36 @@ async function acquireInstallLock(commonDirectory) {
}
const existingRecord = readInstallLock(lockPath)
if (existingRecord === undefined) continue
const verifiedStat = installLockStat(lockPath)
if (verifiedStat === undefined) continue
if (!verifiedStat.isFile() || verifiedStat.isSymbolicLink()) {
throw manualLockRecoveryError(lockPath, 'invalid')
}
if (verifiedStat.dev !== existingStat.dev || verifiedStat.ino !== existingStat.ino) continue
const owner = parseInstallLock(existingRecord)
if (owner === undefined) throw manualLockRecoveryError(lockPath, 'invalid')
if (owner === undefined) {
if (!installLockRecordMayBeIncomplete(existingRecord)) {
throw manualLockRecoveryError(lockPath, 'invalid')
}
const now = Date.now()
if (
initializingLock === undefined
|| initializingLock.dev !== existingStat.dev
|| initializingLock.ino !== existingStat.ino
) {
initializingLock = {
deadline: now + INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS,
dev: existingStat.dev,
ino: existingStat.ino,
}
}
if (now >= initializingLock.deadline) {
throw manualLockRecoveryError(lockPath, 'invalid')
}
await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
continue
}
initializingLock = undefined
if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale')
if (Date.now() >= deadline) {
throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`)
+18 -2
View File
@@ -196,7 +196,7 @@ function runInstaller(
})
}
describe('worktree-local Lefthook installer', () => {
describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
for (const [label, extraEnv] of [
['CI', { CI: 'true' }],
['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
@@ -305,7 +305,23 @@ describe('worktree-local Lefthook installer', () => {
expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook)
expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false)
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
}, 15_000)
})
it('waits for a concurrent installer to finish publishing its lock record', async () => {
const fixture = createFixture()
const lockPath = installLockPath(fixture)
const publishing = runInstaller(fixture, fixture.main, {
DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS: '200',
})
await waitForPath(lockPath)
expect(readFileSync(lockPath, 'utf8')).toBe('')
const waiting = runInstaller(fixture, fixture.linked)
const results = await Promise.all([publishing, waiting])
for (const result of results) expect(result.status, result.stderr).toBe(0)
expect(existsSync(lockPath)).toBe(false)
})
it('repairs its owned absolute hook path after the checkout moves', async () => {
const fixture = createFixture()
+1
View File
@@ -31,6 +31,7 @@
"apps/web/tests/message-actions.e2e.ts",
"apps/web/tests/queue-actions.e2e.ts",
"apps/web/tests/skill-invocation-policy.e2e.ts",
"apps/web/tests/access-confirmation.e2e.ts",
"apps/cli/tests/**/*.ts",
"examples/*/src/**/*.ts",
"examples/*/start.ts",
+1
View File
@@ -130,6 +130,7 @@ export default defineConfig({
'packages/client/ui-primitives/src/markdown/plain-text.ts',
'packages/client/ui-question/src/client/QuestionComposer.tsx',
'packages/client/ui-primitives/src/Menu.tsx',
'packages/client/ui-primitives/src/RiskConfirmation.tsx',
'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx',
'packages/client/ui-workspace/src/client/WorkspacePicker.tsx',
'packages/client/web-react/src/*',