feat(client): confirm before enabling full access

This commit is contained in:
ZiyaZhang
2026-07-30 04:50:18 -07:00
parent e6a621d17d
commit 9eb0ef4c3e
13 changed files with 289 additions and 52 deletions
@@ -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>
</>
)
}