From 25265b312c2e3cb1788e0c195cc4f8ba7b0ac0e5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:56:43 +0800 Subject: [PATCH] feat(web): projection-fed permission chip replaces the Access placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PermissionSelect returns as the Access seat's wired occupant: options and the current value read from the 'permissions' projection through the standard-kit useProjection (no fetch, no mount timing — the resident composer's mount-once fetch bug dies with the fetch), key absence renders nothing (permission-less host, or a Draft with no session), and a pick submits the '/permission ' command line through the new ComposerBarInjected.command callback (Session.command = command.execute admission; the pushed projection frame lands the confirmed value). The READONLY_OPTIONS placeholder and its local state leave InputBar. The connection fixture mirrors the host: a permissions unit fold (three knob events over the fixture preset table), the projections block + baseline/push frames carry the key, and /permission joins the command catalog with the same switch-through-knob-events handler shape. --- .../client/connection/src/client/fixture.ts | 69 ++++++++++++++++ .../connection/tests/fixture-commands.spec.ts | 2 +- .../client/connection/tests/fixture.spec.ts | 25 ++++-- .../runtime/src/client/sessions/session.ts | 15 ++++ packages/client/ui-conversation/package.json | 5 +- .../ui-conversation/src/client/apply.ts | 6 ++ .../src/client/contract/slots.ts | 6 ++ .../src/client/skeleton/InputBar.tsx | 31 +++---- .../skeleton/PermissionSelect.module.css | 49 ++++++++++++ .../src/client/skeleton/PermissionSelect.tsx | 80 +++++++++++++++++++ .../ui-conversation/tests/input-bar.spec.tsx | 36 +++++++-- .../tests/input-matrix.spec.tsx | 1 + .../tests/input-scenarios.spec.tsx | 1 + .../ui-conversation/tests/skeleton.spec.tsx | 1 + packages/client/ui-conversation/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 16 files changed, 295 insertions(+), 38 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 56d8b90d34..3139cd3770 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -303,6 +303,42 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi } /** Fixture parallel of the host's projection units: whole current values per key over the full log. */ +/** Fixture preset table (the host PermissionService defaults). */ +const PERMISSION_PRESETS: Record = { + 'workspace-write': { sandbox: 'workspace-write', approval: 'ask', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, + 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', description: 'Full file access without approval prompts.' }, +} + +/** Host permissions-unit parallel: fold the three knob events, derive the select over the fixture defaults. */ +function permissionSelectOf(log: readonly SessionEvent[]): { options: { value: string; name: string; description?: string }[]; currentValue: string } { + let preset: string | null = null + let sandbox = 'workspace-write' + let approval = 'ask' + for (const event of log) { + const item = event as { type: string; data: Record } + if (item.type === 'permission/preset') preset = item.data['preset'] as string + else if (item.type === 'sandbox/mode') sandbox = item.data['mode'] as string + else if (item.type === 'approval/policy') approval = item.data['policy'] as string + } + const matches = (spec: { sandbox: string; approval: string }): boolean => spec.sandbox === sandbox && spec.approval === approval + let currentValue = 'custom' + const folded = preset === null ? undefined : PERMISSION_PRESETS[preset] + if (preset !== null && folded !== undefined && matches(folded)) { + currentValue = preset + } else { + for (const [name, spec] of Object.entries(PERMISSION_PRESETS)) { + if (matches(spec)) { currentValue = name; break } + } + } + return { + options: [ + ...Object.entries(PERMISSION_PRESETS).map(([value, spec]) => ({ value, name: value, description: spec.description })), + ...currentValue === 'custom' ? [{ value: 'custom', name: 'Custom', description: 'Current sandbox and approval settings do not match a preset.' }] : [], + ], + currentValue, + } +} + function projectionValuesOf(log: readonly SessionEvent[]): Record { const values: Record = {} const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title') @@ -311,6 +347,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record' } }, + { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, ], }) }, @@ -953,6 +1002,26 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim()) const name = match?.[1] const args = match?.[2] ?? '' + // /permission mirrors the host handler: switch through the knob + // events (each append pushes a permissions projection frame). + if (name === 'permission') { + const preset = args.trim() + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const spec = PERMISSION_PRESETS[preset] + if (preset === '') { + const current = permissionSelectOf(logOf(id)).currentValue + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } }) + } else if (spec === undefined) { + append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } }) + } else { + if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } }) + append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } }) + append(id, { type: 'approval/policy', data: { policy: spec.approval } }) + append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } }) + } + return ok(request, { matched: true as const, commandId }) + } const outcomes: Record = { compact: 'fixture:已压缩(假动作)', echo: args.trim(), diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index bd66d124a4..f4d5a290f7 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => { expect(response.rpcId).toBe(request.rpcId) if (!response.result.ok) throw new Error('list failed') const commands = response.result.value.commands - expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture']) + expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture', 'permission']) // input hint rides only the commands declaring it. const echo = commands.find(c => c.name === 'echo') expect(echo?.input?.hint).toBeTruthy() diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 7ba49dd5f5..1f2d9f4f4e 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -71,7 +71,17 @@ describe('createFixtureApi', () => { if (!empty.result.ok) throw new Error('empty failed') // Fixture composes the todos unit (host parallel when tool-todo is mounted): null before any write. expect(empty.result.value).toEqual({ - events: [], hasMore: false, projections: { asOfSeq: -1, values: { todos: null } }, + events: [], hasMore: false, projections: { asOfSeq: -1, values: { + todos: null, + // Permission unit composed: the composition-default select. + permissions: { + options: [ + { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, + { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }, + ], + currentValue: 'workspace-write', + }, + } }, }) }) @@ -206,7 +216,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 4) abort.abort() + if (envelopes.length >= 5) abort.abort() } return envelopes } @@ -214,13 +224,14 @@ describe('createFixtureApi', () => { const second = await openOnce() expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - // Projection baseline frames follow the subscribed frame (title + todos units). + // Projection baseline frames follow the subscribed frame (title + todos + permissions units). expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' }) expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' }) - expect(first[3]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[3]?.rpcId).toBe(first[3]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[4]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[4]?.rpcId).toBe(first[4]?.rpcId) + expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' }) + expect(first[4]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[4]?.rpcId).toBe(first[4]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[5]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[5]?.rpcId).toBe(first[5]?.rpcId) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 00398d53af..855d28afd8 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -248,6 +248,21 @@ export class Session implements ObservableSnapshot { return result } + /** + * Execute one slash-command line against this session's agent — pure + * admission semantics (the host executor durably logs the lifecycle; + * outcomes render as flow nodes, never as a response echo). + * @param line - the full command line, leading slash included. + * @returns the admission result, or the error branch on transport failure. + */ + async command(line: string): Promise> { + try { + return (await this.api.commands.execute({ sessionId: this.sessionId, line })).result + } catch (error) { + return transportError(error) + } + } + /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ open(): Promise { if (this.openState === 'open') return Promise.resolve() diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 0b184dcbdf..fdf8386b0a 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -49,13 +49,14 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 75ed09e78e..55416accc3 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -150,6 +150,12 @@ export function apply(ctx: Context): void { // Stop failure surfaces via snapshot.promptError; nothing to restore. }) }, + command: async (line) => { + const session = sessions.binding(sessionId)?.session + if (session === undefined) return false + const result = await session.command(line) + return result.ok && result.value.matched + }, hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index bddfafd6c7..379c33a8c8 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -250,6 +250,12 @@ export interface ComposerBarInjected { keyboard: ComposerKeyboard /** Cancel the in-flight turn. */ stop: () => void + /** + * Submit one slash-command line against this session's agent (the chrome + * controls' write path — the permission chip submits `/permission `). + * Resolves admission: false = rejected/unmatched/transport failure. + */ + command: (line: string) => Promise /** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */ hooks: { /** Latest surfaced notice (null after none; seq keys re-render of repeats). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 26a65def6a..821e309d89 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -6,12 +6,13 @@ * region-slot content) ride the owner props. Session facts * (running/removed/promptError) are self-selected via useSession. */ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' +import { PermissionSelect } from './PermissionSelect.tsx' import css from './InputBar.module.css' /** Prompt failure surface (derived from promptError). */ @@ -22,13 +23,8 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps -const READONLY_OPTIONS: readonly { id: string; label: string }[] = [ - { id: 'readonly', label: 'Read-only' }, - { id: 'readwrite', label: 'Read-write' }, -] - export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, + useSession, useInput, inputActions, keyboard, stop, command, renderSlot, useNotices, useLexicon, useProjection, variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) @@ -58,9 +54,9 @@ export function InputBar({ }, 10) } - // Placeholder chrome: Access selection stays local until its seam lands - // (plan/model are real seats now — the named single slots below). - const [readonlyId, setReadonlyId] = useState('readonly') + // The Access seat's data: the host-computed permissions projection + // (undefined = capability absent → the chip renders nothing). + const permissions = useProjection('permissions') // Queue cut 1: running input stays free; locked = session disabled only. // The transient machine locks (adjudicating pending / submitting) render @@ -223,19 +219,10 @@ export function InputBar({ if (!empty && !disabled && !machineBusy) inputActions.submit('queue') } - // Access placeholder select (the one remaining local-chrome control). + // The Access seat: the projection-fed permission chip (renders nothing + // while the permissions key is absent — permission-less host or Draft). const accessSelect: ReactNode = ( - + ) // Mirror-layer decorations: a visible backdrop with transparent text. The diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css new file mode 100644 index 0000000000..dd5986992c --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -0,0 +1,49 @@ +/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a + quiet text chip with a chevron; hover paints the standard interactive pill. + The native select is stretched invisibly over the chip so the platform + dropdown does the menu work — keyboard/AT semantics come free. */ + +.root { + position: relative; + display: inline-flex; + align-items: center; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + border-radius: 8px; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 20px; + pointer-events: none; /* the overlaid select owns the interaction */ +} + +.root:hover .chip { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chevron { + color: var(--dsw-alias-label-caption); +} + +/* Invisible native select stretched over the chip: real menu, zero drawing. */ +.select { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + border: none; + cursor: pointer; +} + +.select:disabled { + cursor: default; +} + +.root:has(.select:disabled) .chip { + opacity: 0.5; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx new file mode 100644 index 0000000000..0622e64500 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -0,0 +1,80 @@ +// PermissionSelect: the composer bottom-row permission chip (draft +// start.jpeg's `Read-only ∨` control), the Access seat's wired occupant. +// Options and the current value read from the host-computed `permissions` +// projection (baseline block + push frames — no fetch, no mount timing); +// key absence (a permission-less composition, or a Draft with no host +// session yet) renders nothing. The visible chip is presentation only — an +// invisible native select stretched over it owns the menu and interaction. +// A switch submits the `/permission ` command line (the one write +// path); the control shows the picked value optimistically and disables +// until the admission result, then re-follows the projection — the pushed +// frame confirms the switch, and a failed/unmatched submit falls back to +// the still-authoritative projection value (`custom` is shown as the +// current value but never offered as a target — the host omits it from +// switchable options). + +import { useState } from 'react' +import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' +import css from './PermissionSelect.module.css' + +/** + * Display transform: kebab-case machine names render as title-case labels + * (`workspace-write` → `Workspace Write`). Presentation-only — the wire + * vocabulary and the host's advertised names are untouched; a host-configured + * name that is not kebab-case (contains spaces or uppercase) passes 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(' ') +} + +export interface PermissionSelectProps { + /** The host-computed select, or undefined while the capability is absent. */ + value: PermissionSelectValue | undefined + /** Session-removed lock (the bar's chrome disable state). */ + locked: boolean + /** Submit one slash-command line; resolves admission (false = rejected/unmatched). */ + command: (line: string) => Promise +} + +export function PermissionSelect({ value, locked, command }: PermissionSelectProps) { + // Optimistic pick, shown while the admission round-trip runs; null follows + // the projection (the pushed frame lands the confirmed value there). + const [pick, setPick] = useState(null) + if (value === undefined) return null + + const currentValue = pick ?? value.currentValue + const current = value.options.find(option => option.value === currentValue) + + const onChange = (next: string): void => { + if (next === value.currentValue) return + setPick(next) + void command(`/permission ${next}`) + .catch(() => false) + .then(() => { setPick(null) }) + } + + return ( + + ) +} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index f8b9fd3d67..0aea847bfe 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -33,6 +33,7 @@ interface BenchOptions { modelEntry?: React.ReactNode /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */ lexicon?: ReadonlyMap<'/' | '@', readonly string[]> + permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string } draft?: string running?: boolean disabled?: boolean @@ -88,13 +89,14 @@ function bench(over?: BenchOptions) { items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), - useProjection: (() => undefined), + useProjection: ((key: string) => (key === 'permissions' ? over?.permissions : undefined)) as InputBarProps['useProjection'], useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), stop, + command: () => Promise.resolve(true), renderSlot, variant: over?.variant ?? 'composer', ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), @@ -352,16 +354,37 @@ describe('strips and variants', () => { }) describe('placeholder chrome and control seats', () => { - it('renders attach + Access placeholder; plan/model seats render EMPTY without entries (B ruling)', () => { + it('renders attach; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => { const { view, slotCalls } = bench() expect(view.getByLabelText('Add attachment')).toBeTruthy() - expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly') + // Capability absent (no projection value): the chip renders nothing. + expect(view.queryByLabelText('Access mode')).toBeNull() // Both seats dispatched, nothing rendered. expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model']) expect(view.queryByLabelText('Plan mode')).toBeNull() expect(view.queryByLabelText('Model')).toBeNull() }) + it('the Access chip renders the projection value and submits /permission on pick', async () => { + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view } = bench({ permissions }) + const select = view.getByLabelText('Access mode') as HTMLSelectElement + expect(select.value).toBe('workspace-write') + // Title-case display is presentation only; the option values stay machine names. + expect([...select.options].map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access']) + fireEvent.change(select, { target: { value: 'danger-full-access' } }) + // Optimistic pick + disable until admission resolves (command stub resolves true). + expect(select.disabled).toBe(true) + await act(async () => {}) + expect(select.disabled).toBe(false) + }) + it('a registered entry fills its seat and receives the locked owner prop', () => { const { view, slotCalls } = bench({ disabled: true, @@ -377,12 +400,13 @@ describe('placeholder chrome and control seats', () => { expect(live.slotCalls.every(c => !(c.owner as { locked: boolean }).locked)).toBe(true) }) - it('disabled locks the Access placeholder and attach control (running does not)', () => { - const { view } = bench({ disabled: true }) + it('disabled locks the Access chip and attach control (running does not)', () => { + const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } + const { view } = bench({ disabled: true, permissions }) expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true) cleanup() - const live = bench({ running: true }) + const live = bench({ running: true, permissions }) expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false) }) }) diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 8a04dde4a5..bcdecc9a02 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -47,6 +47,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), + command: () => Promise.resolve(true), variant: 'composer', } return render() diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 4464f4876c..9d9ace032c 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -133,6 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), + command: () => Promise.resolve(true), variant: 'composer', } const view = render() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b6263929cf..0d32e2edea 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -123,6 +123,7 @@ function mount( useNotices={bindSnapshotSelector(wiring.notices)} useLexicon={bindSnapshotSelector(wiring.lexicon)} stop={stop} + command={() => Promise.resolve(true)} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} /> diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 32ba48e8fe..9df35ff30f 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../ui/permission" } ], "exclude": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d1760f9e1..5834d885fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -982,6 +982,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection