feat(web): projection-fed permission chip replaces the Access placeholder
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 <preset>' 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.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user