feat(permission): add new-session default setting

This commit is contained in:
Yichen Jiang
2026-07-31 12:48:19 +08:00
parent 4a061f33d0
commit ee74b5b07a
51 changed files with 1452 additions and 226 deletions

View File

@@ -0,0 +1,60 @@
/* Permission row: title/description plus the preset selector pill. */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 48px;
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.desc {
font-size: 12px;
font-weight: 400;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.selector {
display: inline-flex;
align-items: center;
gap: 12px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
background: var(--dsw-alias-bg-module-platform);
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.selector:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:disabled {
cursor: default;
}
.chevron {
flex: none;
}

View File

@@ -0,0 +1,88 @@
/**
* Permission preference row: the default preset for subsequently created
* sessions. Current-session switches remain on the composer `/permission`
* control.
*/
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 { PermissionSettingsKey } from './locales.ts'
import css from './PermissionRow.module.css'
/** Injected controller and hook for the host-backed preference. */
export interface PermissionRowInjected {
/** Permission settings controller. */
controller: PermissionSettingsController
/** Selector hook bound to the controller snapshot. */
useSnapshot: SnapshotSelectorHook<PermissionSettingsState>
}
/** Full component props. */
export type PermissionRowProps =
PropsRuntime<'settings.general.item'> & PropsLocale<'settings.permission'> & 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)
const [open, setOpen] = useState(false)
useEffect(() => {
void controller.load()
}, [controller])
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 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>
<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)
}}
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>
)
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Permission row copy. */
'settings.permission': PermissionSettingsKey
}
}

View File

@@ -1,46 +1,46 @@
/**
* 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.
* 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.
*/
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 { PermissionRow } from './PermissionRow.tsx'
import type { PermissionRowInjected } from './PermissionRow.tsx'
import { en, zh } from './locales.ts'
import { displayPresetName } from './presentation.ts'
import {
PERMISSION_SETTINGS_NS, PermissionSettingsController, refreshPermissionIfLoaded,
} from './settings-store.ts'
export type { PermissionRowInjected, PermissionRowProps } from './PermissionRow.tsx'
export type {
PermissionDefaultOption, PermissionSettingsState,
} from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['command', 'sessions']
export const inject = ['command', 'sessions', 'slots', 'locale', 'connection']
/** 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
}
/**
* 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.
*/
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(' ')
}
/** Flatten the projection select into popup rows; `custom` is display state, never a target. */
function optionsOf(value: PermissionSelect): SelectOption[] {
return value.options
.filter(option => option.value !== 'custom')
.map(option => ({
id: option.value,
label: displayName(option.name),
label: displayPresetName(option.name),
...(option.description !== undefined ? { detail: option.description } : {}),
...(option.value === value.currentValue ? { active: true } : {}),
}))
@@ -56,6 +56,41 @@ export function apply(ctx: ClientContext): void {
const sessions = ctx.sessions
const sessionFor = (session: ClientSessionContext): SessionFace | undefined =>
sessions.binding(session.sessionId)?.session
ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries')
const connection = ctx.get('connection') as ConnectionHandle
const controller = new PermissionSettingsController(connection.api)
const useSnapshot = bindSnapshotSelector(controller.store)
const injected = (): PermissionRowInjected => ({ controller, useSnapshot })
ctx.effect(() => {
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== PERMISSION_SETTINGS_NS) return
refreshPermissionIfLoaded(controller)
}
const disposers = [
ctx.on('settings/changed', refresh),
ctx.on('connection/reset', () => { refresh() }),
]
return () => {
controller.dispose()
for (const dispose of disposers) dispose()
}
}, 'ui-permission: settings invalidations')
ctx.effect(() => {
const row = deferRegistration(ctx.slots, 'settings.general.item', PermissionRow, () =>
ctx.slots.register({
name: 'settings.general.item',
id: 'permission',
order: -20,
locale: 'settings.permission',
inject: injected,
}, PermissionRow))
return () => { row.dispose() }
}, 'ui-permission: General settings row')
ctx.effect(() => command.decorate({
name: 'permission',
// The picker exists exactly while the projection does: a permission-less

View File

@@ -0,0 +1,20 @@
/** `settings.permission` namespace dictionaries (the Permission row's copy). */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'title': '权限',
'description': '选择新会话的默认权限模式',
'loading': '加载中',
'unavailable': '不可用',
} satisfies Record<string, string>
/** The settings.permission namespace key union. */
export type PermissionSettingsKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'title': 'Permission',
'description': 'Choose the default permission mode for new sessions',
'loading': 'Loading',
'unavailable': 'Unavailable',
} satisfies Record<PermissionSettingsKey, string>

View File

@@ -0,0 +1,9 @@
/**
* Convert conventional kebab-case preset names into user-facing title case.
* @param name - host-supplied preset label or key.
* @returns the title-cased conventional key, or a non-kebab label unchanged.
*/
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(' ')
}

View File

@@ -0,0 +1,191 @@
/**
* Permission default-settings controller. The host descriptor supplies the
* current value and the dynamic preset enum; writes target only
* `defaultPreset` and carry the descriptor revision.
*/
import type {
IApiClient, SettingsNamespaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import {
createSnapshotStore, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
nodeAtPath, rehydrateSchema, type SchemaNode,
} from '@deepseek-ai/dsh-client-schema-form'
import { displayPresetName } from './presentation.ts'
/** Permission's settings namespace on the host wire. */
export const PERMISSION_SETTINGS_NS = 'permission'
/** One selectable new-session default. */
export interface PermissionDefaultOption {
/** Preset key written to Settings. */
id: string
/** Host-supplied label or a title-cased preset key. */
label: string
}
/** Permission settings-row snapshot. */
export interface PermissionSettingsState {
status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
error: string | null
writable: boolean
currentValue: string
options: readonly PermissionDefaultOption[]
revision: number
}
interface ConstChoice {
type: string
value?: unknown
meta?: { description?: unknown }
}
/**
* Read the dynamic preset enum encoded by the host's `defaultPreset` schema.
* @param view - permission namespace descriptor.
* @returns current value and selectable options.
*/
export function permissionDefaultOf(view: SettingsNamespaceView): {
currentValue: string
options: PermissionDefaultOption[]
} {
const value = (view.value as { defaultPreset?: unknown } | null)?.defaultPreset
if (typeof value !== 'string') throw new Error('permission settings has no defaultPreset value')
const node = nodeAtPath(rehydrateSchema(view.schema), ['defaultPreset'])
if (node === undefined) throw new Error('permission settings schema has no defaultPreset field')
const rawChoices = node.type === 'union'
? (node.list as SchemaNode[] | undefined) ?? []
: [node]
const options = rawChoices.flatMap((candidate) => {
const choice = candidate as unknown as ConstChoice
if (choice.type !== 'const' || typeof choice.value !== 'string') return []
const described = choice.meta?.description
return [{
id: choice.value,
label: typeof described === 'string' && described.length > 0
? displayPresetName(described)
: displayPresetName(choice.value),
}]
})
if (options.length === 0 || !options.some(option => option.id === value)) {
throw new Error('permission settings schema does not advertise its current preset')
}
return { currentValue: value, options }
}
/** Controller joining Settings reads, writes, and pushed invalidations. */
export class PermissionSettingsController {
/** Row snapshot consumed through a bound selector hook. */
readonly store: SnapshotStore<PermissionSettingsState> = createSnapshotStore({
status: 'idle',
error: null,
writable: false,
currentValue: '',
options: [],
revision: 0,
})
private generation = 0
private view: SettingsNamespaceView | undefined
/** @param api - Settings wire face. */
constructor(private readonly api: Pick<IApiClient, 'settings'>) {}
/**
* Refresh the permission descriptor. Latest request wins.
* @returns nothing; {@link store} carries success or failure.
*/
async load(): Promise<void> {
const generation = ++this.generation
this.store.update((state) => {
state.status = 'loading'
state.error = null
})
try {
const response = await this.api.settings.describe({})
if (!response.result.ok) throw new Error(response.result.error.message)
if (generation !== this.generation) return
const view = response.result.value.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS)
if (view === undefined) {
this.view = undefined
this.store.update((state) => {
state.status = 'unavailable'
state.writable = false
state.currentValue = ''
state.options = []
})
return
}
this.accept(view, response.result.value.writable)
} catch (error) {
if (generation !== this.generation) return
this.fail(error)
}
}
/**
* Persist one preset as the default for subsequently created sessions.
* @param preset - advertised preset key.
* @returns nothing; {@link store} carries success or failure.
*/
async select(preset: string): Promise<void> {
const view = this.view
const state = this.store.getSnapshot()
if (view === undefined || !state.writable) return
const generation = ++this.generation
this.store.update((draft) => {
draft.status = 'saving'
draft.error = null
})
try {
const response = await this.api.settings.mutate({
ns: PERMISSION_SETTINGS_NS,
ops: [{ op: 'set', path: ['defaultPreset'], value: preset }],
expectedRevision: view.revision,
})
if (generation !== this.generation) return
if (!response.result.ok) throw new Error(response.result.error.message)
this.accept(response.result.value, true)
} catch (error) {
if (generation !== this.generation) return
this.fail(error)
}
}
/** Stop in-flight responses from publishing after plugin disposal. */
dispose(): void {
this.generation += 1
this.view = undefined
}
private accept(view: SettingsNamespaceView, writable: boolean): void {
const resolved = permissionDefaultOf(view)
this.view = view
this.store.update((state) => {
state.status = 'ready'
state.error = null
state.writable = writable
state.currentValue = resolved.currentValue
state.options = resolved.options
state.revision = view.revision
})
}
private fail(error: unknown): void {
this.store.update((state) => {
state.status = 'error'
state.error = error instanceof Error ? error.message : String(error)
})
}
}
/**
* Refetch only after the row has opened once.
* @param controller - permission settings controller.
*/
export function refreshPermissionIfLoaded(controller: PermissionSettingsController): void {
if (controller.store.getSnapshot().status === 'idle') return
void controller.load()
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}

View File

@@ -1,8 +1,8 @@
/**
* Permission preset selection plugin, node half. Pure UI plugin: the empty
* apply exists so the plugin appears in the host cordis.yml / Loader; the
* browser half ships via exports["./client"], discovered through the
* package.json dshClient declaration.
* Permission surfaces plugin, node half. The empty apply exists so the plugin
* appears in the host cordis.yml / Loader; the browser half ships the
* new-session Settings row and current-session command picker through
* exports["./client"], discovered from the package.json dshClient declaration.
*/
/** Host plugin body — no host-side behavior for this surface plugin. */

View File

@@ -15,9 +15,9 @@ export const name = 'client-ui-permission-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a single command contribution registration whose disposal is
* proven by the HMR-safety spec — it emits no cordis events and owns no
* cross-plugin mutable state.
* No runtime invariant: the command and slot contribution lifecycles are
* proven by the HMR-safety spec, while the browser-only Settings controller
* owns no host events or cross-plugin mutable state.
*/
const install: InvariantInstaller = () => {}