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:
imccyu
2026-07-28 22:56:43 +08:00
parent 451b7b0446
commit 25265b312c
16 changed files with 295 additions and 38 deletions

View File

@@ -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 },
}
},

View File

@@ -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 <preset>`).
* Resolves admission: false = rejected/unmatched/transport failure.
*/
command: (line: string) => Promise<boolean>
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */

View File

@@ -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 = (
<select
className={css.select}
aria-label="Access mode"
value={readonlyId}
disabled={locked}
onChange={(e: ChangeEvent<HTMLSelectElement>) => { setReadonlyId(e.target.value) }}
>
{READONLY_OPTIONS.map(opt => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
<PermissionSelect value={permissions} locked={locked} command={command} />
)
// Mirror-layer decorations: a visible backdrop with transparent text. The

View File

@@ -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;
}

View File

@@ -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 <preset>` 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<boolean>
}
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<string | null>(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 (
<label className={css.root} title={current?.description}>
<span className={css.chip}>
{displayName(current?.name ?? currentValue)}
<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>
</span>
<select
className={css.select}
aria-label="Access mode"
value={currentValue}
disabled={locked || pick !== null}
onChange={(e) => { onChange(e.target.value) }}
>
{value.options.map(option => (
<option key={option.value} value={option.value} disabled={option.value === 'custom'}>
{displayName(option.name)}
</option>
))}
</select>
</label>
)
}