mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(client): confirm before enabling full access
This commit is contained in:
@@ -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)).
|
||||
|
||||
|
||||
@@ -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: '<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: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -284,6 +284,8 @@ export interface ComposerBarInjected {
|
||||
command: ((line: string) => Promise<boolean>) | 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
|
||||
|
||||
@@ -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
|
||||
: <PermissionSelect value={permissions} locked={locked} command={command} />
|
||||
: <PermissionSelect key={sessionId} value={permissions} locked={locked} command={command} t={translateAccess} />
|
||||
|
||||
// Mirror-layer decorations: a visible backdrop with transparent text. The
|
||||
// claim token highlights through behind the textarea glyphs; each U+FFFC
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<boolean>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
export function PermissionSelect({ value, locked, command }: 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)
|
||||
|
||||
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 (
|
||||
<Menu
|
||||
open={open}
|
||||
items={items}
|
||||
selectedId={currentValue}
|
||||
onSelect={choose}
|
||||
onClose={() => { setOpen(false) }}
|
||||
side="top"
|
||||
anchor={
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={`Access mode, current: ${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={`Access mode, current: ${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>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
open={confirmation !== null}
|
||||
onClose={closeConfirmation}
|
||||
title={t('confirm.title')}
|
||||
className={css.confirmation ?? ''}
|
||||
contentClassName={css.confirmationContent ?? ''}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction} onClick={closeConfirmation}>
|
||||
{t('confirm.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.confirmAction}
|
||||
disabled={!acknowledged}
|
||||
onClick={confirmFullAccess}
|
||||
>
|
||||
{t('confirm.enable')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.warning}>
|
||||
<IconWarningOutline16 size={18} className={css.warningIcon} />
|
||||
<p>{t('confirm.description')}</p>
|
||||
</div>
|
||||
<label className={css.acknowledgement}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
autoFocus
|
||||
onChange={(event) => { setAcknowledged(event.currentTarget.checked) }}
|
||||
/>
|
||||
<span>{t('confirm.acknowledge')}</span>
|
||||
</label>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ interface BenchOptions {
|
||||
variant?: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
translateHint?: (key: string) => string
|
||||
translateAccess?: (key: string) => string
|
||||
command?: (line: string) => Promise<boolean>
|
||||
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<string, string>)[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<string, string>)[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', () => {
|
||||
|
||||
@@ -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(<InputBar {...props} />)
|
||||
|
||||
@@ -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(<InputBar {...barProps} />)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
: (
|
||||
<>
|
||||
<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="Close" onClick={onClose}>
|
||||
|
||||
@@ -324,11 +324,12 @@ 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" description="Name it." footer={<button type="button">Create</button>}>
|
||||
<Modal open onClose={onClose} title="Create new workspace" 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()
|
||||
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' })
|
||||
|
||||
Reference in New Issue
Block a user