merge master and address permission settings review

This commit is contained in:
Yichen Jiang
2026-07-31 13:24:30 +08:00
89 changed files with 1336 additions and 295 deletions

View File

@@ -5,78 +5,125 @@
*/
import { useEffect, useState } from 'react'
import type {
PropsLocale, PropsRuntime, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
PermissionSettingsController, PermissionSettingsState,
} from './settings-store.ts'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import {
IconChevronDownOutline14, Menu, RiskConfirmation,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PermissionSettingsState } from './settings-store.ts'
import type { PermissionSettingsKey } from './locales.ts'
import { FULL_ACCESS_PRESET } from './presentation.ts'
import css from './PermissionRow.module.css'
/** Injected controller and hook for the host-backed preference. */
/** Registration-side business face for the host-backed preference. */
export interface PermissionRowInjected {
/** Permission settings controller. */
controller: PermissionSettingsController
/** Selector hook bound to the controller snapshot. */
useSnapshot: SnapshotSelectorHook<PermissionSettingsState>
hooks: {
/** Permission settings snapshot bound by the renderer as usePermission. */
permission: SnapshotStore<PermissionSettingsState>
}
/** Load the descriptor when the row first renders. */
load: () => Promise<void>
/** Persist one advertised preset. */
select: (preset: string) => Promise<void>
}
/** Full component props. */
export type PermissionRowProps =
PropsRuntime<'settings.general.item'> & PropsLocale<'settings.permission'> & PermissionRowInjected
PropsRuntime<'settings.general.item'>
& PropsLocale<'settings.permission'>
& InjectFace<PermissionRowInjected>
/**
* Render the new-session Permission default selector.
* @param props - composed slot props.
* @returns the row, or null when the host does not expose permission settings.
*/
export function PermissionRow({ controller, useSnapshot, t }: PermissionRowProps) {
const state = useSnapshot(snapshot => snapshot)
export function PermissionRow({ load, select, usePermission, t }: PermissionRowProps) {
const state = usePermission(snapshot => snapshot)
const [open, setOpen] = useState(false)
const [confirmation, setConfirmation] = useState<string | null>(null)
const [acknowledged, setAcknowledged] = useState(false)
useEffect(() => {
void controller.load()
}, [controller])
void load()
}, [load])
useEffect(() => {
if (state.writable && state.status !== 'unavailable') return
setOpen(false)
setAcknowledged(false)
setConfirmation(null)
}, [state.status, state.writable])
if (state.status === 'unavailable') return null
const selected = state.options.find(option => option.id === state.currentValue)
const busy = state.status === 'loading' || state.status === 'saving'
const busy = state.status === 'loading' || state.status === 'saving' || confirmation !== null
const label = selected?.label
?? (busy ? t('loading') : t('unavailable'))
const description: string = state.error ?? t('description')
return (
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('title')}</div>
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
<>
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('title')}</div>
<div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
</div>
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={state.options.map(option => ({ id: option.id, label: option.label }))}
selectedId={state.currentValue}
onSelect={(id) => {
setOpen(false)
if (id === state.currentValue) return
if (id === FULL_ACCESS_PRESET) {
setAcknowledged(false)
setConfirmation(id)
return
}
void select(id)
}}
align="end"
portal
anchor={(
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={open}
disabled={busy || !state.writable || state.options.length === 0}
onClick={() => { setOpen(value => !value) }}
>
{label}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
</div>
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={state.options.map(option => ({ id: option.id, label: option.label }))}
selectedId={state.currentValue}
onSelect={(id) => {
setOpen(false)
void controller.select(id)
<RiskConfirmation
open={confirmation !== null}
title={t('confirm.title')}
description={t('confirm.description')}
acknowledgeLabel={t('confirm.acknowledge')}
cancelLabel={t('confirm.cancel')}
confirmLabel={t('confirm.enable')}
acknowledged={acknowledged}
disabled={!state.writable || state.status === 'saving'}
onAcknowledgedChange={setAcknowledged}
onCancel={() => {
setAcknowledged(false)
setConfirmation(null)
}}
onConfirm={() => {
if (!acknowledged || confirmation === null) return
const preset = confirmation
setAcknowledged(false)
setConfirmation(null)
void select(preset)
}}
align="end"
portal
anchor={(
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={open}
disabled={busy || !state.writable || state.options.length === 0}
onClick={() => { setOpen(value => !value) }}
>
{label}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
</div>
</>
)
}

View File

@@ -1,22 +1,34 @@
/**
* Permission plugin, browser half. The General-settings row writes the
* default preset for subsequently created sessions through Settings; the
* `/permission` popup decoration switches the current session through the
* host command and its `permissions` projection.
* Permission preset plugin, browser half — a popupSelect DECORATION hung on
* the host `/permission` command: one flat list of presets, current value
* marked active, a pick executes the switch. The decoration owns only the
* bare invocation; the host command keeps its catalog row, the argued path
* (`/permission <preset>` still switches directly), and the lifecycle
* logging. Options and the active mark read the session's `permissions`
* 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. The Full access row carries the same explicit risk gate as
* the composer chip; the shared popup shell owns the modal mechanics.
* The General-settings row separately writes the default preset for sessions
* created later through the host Settings API.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
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'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
// Type-only: pulls the General item slot and locale service contracts.
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
import { PermissionRow } from './PermissionRow.tsx'
import type { PermissionRowInjected } from './PermissionRow.tsx'
import { en, zh } from './locales.ts'
import { displayPresetName } from './presentation.ts'
import {
accessEn, accessZh, en, zh,
} from './locales.ts'
import {
displayPermissionPreset, FULL_ACCESS_PRESET,
} from './presentation.ts'
import {
PERMISSION_SETTINGS_NS, PermissionSettingsController, refreshPermissionIfLoaded,
} from './settings-store.ts'
@@ -29,20 +41,33 @@ export type {
/** Required services (cordis fiber inject). */
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection']
const ACCESS_NS = 'permission.access'
/** Read one session's current permissions projection value (undefined = capability absent). */
function selectOf(session: SessionFace | undefined): PermissionSelect | undefined {
return session?.projections.faceOf('permissions').getSnapshot() as PermissionSelect | undefined
}
/** 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: displayPresetName(option.name),
label: displayPermissionPreset(option.value, option.name),
...(option.description !== undefined ? { detail: option.description } : {}),
...(option.value === value.currentValue ? { active: true } : {}),
...(option.value === FULL_ACCESS_PRESET
? {
confirmation: {
title: t('confirm.title'),
description: t('confirm.description'),
acknowledgeLabel: t('confirm.acknowledge'),
cancelLabel: t('confirm.cancel'),
confirmLabel: t('confirm.enable'),
},
}
: {}),
}))
}
@@ -54,6 +79,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': accessZh['confirm.title'],
'confirm.description': accessZh['confirm.description'],
'confirm.acknowledge': accessZh['confirm.acknowledge'],
'confirm.cancel': accessZh['confirm.cancel'],
'confirm.enable': accessZh['confirm.enable'],
}),
ctx.locale.register(ACCESS_NS, 'en', {
'confirm.title': accessEn['confirm.title'],
'confirm.description': accessEn['confirm.description'],
'confirm.acknowledge': accessEn['confirm.acknowledge'],
'confirm.cancel': accessEn['confirm.cancel'],
'confirm.enable': accessEn['confirm.enable'],
}),
]
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
@@ -61,8 +110,13 @@ export function apply(ctx: ClientContext): void {
const connection = ctx.get('connection') as ConnectionHandle
const controller = new PermissionSettingsController(connection.api)
const useSnapshot = bindSnapshotSelector(controller.store)
const injected = (): PermissionRowInjected => ({ controller, useSnapshot })
const load = (): Promise<void> => controller.load()
const select = (preset: string): Promise<void> => controller.select(preset)
const injected = (): PermissionRowInjected => ({
hooks: { permission: controller.store },
load,
select,
})
ctx.effect(() => {
const refresh = (ns?: string): void => {
@@ -102,7 +156,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)

View File

@@ -6,6 +6,11 @@ export const zh = {
'description': '选择新会话的默认权限模式',
'loading': '加载中',
'unavailable': '不可用',
'confirm.title': '确认启用 Full access?',
'confirm.description': '启用 Full access 后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
'confirm.acknowledge': '我已了解风险,并愿意继续',
'confirm.cancel': '取消',
'confirm.enable': '启用 Full access',
} satisfies Record<string, string>
/** The settings.permission namespace key union. */
@@ -17,4 +22,30 @@ export const en = {
'description': 'Choose the default permission mode for new sessions',
'loading': 'Loading',
'unavailable': 'Unavailable',
'confirm.title': 'Enable Full access?',
'confirm.description': 'Full access lets new sessions reduce confirmation steps and perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust subsequent tasks.',
'confirm.acknowledge': 'I understand the risks and want to continue',
'confirm.cancel': 'Cancel',
'confirm.enable': 'Enable Full access',
} satisfies Record<PermissionSettingsKey, string>
/** Simplified Chinese dictionary for the current-session popup gate. */
export const accessZh = {
'confirm.title': '确认启用 Full access?',
'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'confirm.acknowledge': '我已了解风险,并愿意继续',
'confirm.cancel': '取消',
'confirm.enable': '启用 Full access',
} satisfies Record<string, string>
/** Current-session popup-gate key union. */
export type PermissionAccessKey = keyof typeof accessZh
/** English dictionary for the current-session popup gate. */
export const accessEn = {
'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',
} satisfies Record<PermissionAccessKey, string>

View File

@@ -1,3 +1,6 @@
/** Machine value of the preset that requires an explicit GUI risk gate. */
export const FULL_ACCESS_PRESET = 'danger-full-access'
/**
* Convert conventional kebab-case preset names into user-facing title case.
* @param name - host-supplied preset label or key.
@@ -7,3 +10,13 @@ export function displayPresetName(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(' ')
}
/**
* Render a permission preset under its product label.
* @param value - preset machine value.
* @param name - host-supplied preset name.
* @returns the Full access product label or the conventional display name.
*/
export function displayPermissionPreset(value: string, name: string): string {
return value === FULL_ACCESS_PRESET ? 'Full access' : displayPresetName(name)
}

View File

@@ -13,7 +13,7 @@ import {
import {
nodeAtPath, rehydrateSchema, type SchemaNode,
} from '@deepseek-ai/dsh-client-schema-form'
import { displayPresetName } from './presentation.ts'
import { displayPermissionPreset } from './presentation.ts'
/** Permission's settings namespace on the host wire. */
export const PERMISSION_SETTINGS_NS = 'permission'
@@ -65,8 +65,8 @@ export function permissionDefaultOf(view: SettingsNamespaceView): {
return [{
id: choice.value,
label: typeof described === 'string' && described.length > 0
? displayPresetName(described)
: displayPresetName(choice.value),
? displayPermissionPreset(choice.value, described)
: displayPermissionPreset(choice.value, choice.value),
}]
})
if (options.length === 0 || !options.some(option => option.id === value)) {