feat(web): permission presets and approval answering for the web UI

The web host now composes the sandboxed product path (sandbox-local +
sandbox-policy behind bash-sandbox/fs-sandbox, with user-approval and
permission on top); BootHostOptions.sandbox carries the deployment
defaults (workspace-write + ask).

createApiProxy owns the approval pending registry: a ctx.approval ask
becomes an answerable approval/requested mux frame with a stable rpcId,
replayed verbatim on every mux open until settled; respond routes by the
echoed rpcId, validates the ApprovalResponsePayload audit correlation,
and broadcasts approval/resolved; the ask's abort signal withdraws the
question as cancelled.

session.permissions / session.setPermission project ctx.permission into
a protocol-owned PermissionOption select; idle switches are held
last-write-wins and
flushed into the next prompted turn (the ACP bridge's anchoring
pattern). The shared hasOpenTurn fold moved to dsh-session,
deduplicating the private copies in user-approval, the ACP bridge, and
the proxy.

Client, per the designer draft: a pending approval takes over the
composer (ApprovalPanel replaces the InputBar — amber strip,
justification headline, paired command, one-shot refuse/allow, keyed by
rpcId so a queued second approval remounts live; the resolved frame
restores the composer); the sidebar session row shows an amber
waiting-approval dot that outranks the running ring (manager-tracked
approvalId set, idempotent under mux-open replays, cleared per
connection generation, lit for uninstantiated sessions too); the
permission selector is a composer bottom-row chip over an invisible
native select, with a presentation-only title-case transform
(workspace-write renders as Workspace Write; wire names untouched). Question placeholders stay in the message flow. The
connection fixture mirrors the host behavior for keyless browser
acceptance.
This commit is contained in:
Turtle
2026-07-24 13:39:00 +08:00
parent 0133e80767
commit f0410d592d
69 changed files with 1744 additions and 139 deletions
@@ -15,12 +15,13 @@ import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ViewTab } from './contract/views.ts'
import type {
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
ApprovalWait, ChatViewInjected, ComposerChainProps, ConversationInjected, DetailsInjected, EmptyStateInjected,
} from './contract/slots.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { EmptyState } from './skeleton/EmptyState.tsx'
@@ -37,6 +38,11 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
return conversation
}
/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null {
return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null
}
/**
* Client plugin body.
* @param ctx - client root context.
@@ -104,10 +110,31 @@ export function apply(ctx: Context): void {
})
},
open: (target: SessionId) => { sessions.open(target) },
permissions: async () => {
const result = await sessions.manager.get(sessionId).permissions()
// Empty options = permission-less host composition: hide the control
// rather than show an empty select (deployment shape, not an error).
if (!result.ok || result.value.options.length === 0) return null
return result.value
},
setPermission: async (value) => {
const result = await sessions.manager.get(sessionId).setPermission(value)
return result.ok ? result.value.currentValue : null
},
}
},
}, ConversationRoot)
// The approval takeover: a selector-routed entry of the chain this package
// just declared (the ui-question registration pattern; the entry lives here
// because approval answering is core conversation UX, not an optional tool).
// Zero business face — data and verbs both ride the matched carrier.
// priority 1: question takeovers (default 0) win when both kinds are
// pending — a question is a conversation the model is waiting on, while an
// approval only blocks one tool call; answering the question first cannot
// strand the approval (it re-elects the moment the question resolves).
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel)
// The chat view: first entry of the ring this package just declared.
// Declaring the keyed toolview hole here is claiming it: ChatView is the
// only component authorized to render per-tool rows. Shares the chat
@@ -254,7 +254,9 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
))}
</div>
)}
{pending.map((item) => <PendingCard key={item.key} item={item} />)}
{/* Approvals take over the composer (ApprovalPanel); only question
placeholders remain in the flow. */}
{pending.filter((item) => item.kind === 'question').map((item) => <PendingCard key={item.key} item={item} />)}
</div>
</div>
<StatsLine useSession={useSession} />
@@ -1,4 +1,4 @@
/* Amber pending strip (approval waiting = warn semantic, figma state colors). */
/* Amber pending strip (question waiting = warn semantic, figma state colors). */
.card {
margin: 6px 0;
@@ -13,19 +13,3 @@
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.mono {
font-family: var(--ds-font-family-code);
}
.reason {
margin-top: 4px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
}
.hint {
margin-top: 6px;
font-size: 11px;
color: var(--dsw-alias-label-tertiary);
}
@@ -1,6 +1,7 @@
// PendingCard: approval/question placeholder card (visible, not answerable
// the composer-takeover approval panel is a P-II item; wire pending semantics
// already exist so the flow must show them).
// PendingCard: question pending placeholder in the message flow (visible
// while the question composer owns the takeover slot elsewhere). Approvals
// do not render here: they take over the composer (skeleton ApprovalPanel)
// per the designer draft.
import { memo } from 'react'
import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
@@ -8,24 +9,14 @@ import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './PendingCard.module.css'
export interface PendingCardProps {
item: PendingInteraction
item: Extract<PendingInteraction, { kind: 'question' }>
}
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
return (
<div className={css.card}>
{item.kind === 'approval' ? (
<>
<div className={css.title}><span className={css.mono}>{item.payload.toolName}</span></div>
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
</>
) : (
<>
<div className={css.title}>{item.payload.questions.length} </div>
<JsonBlock label="问题内容" payload={item.payload.questions} />
</>
)}
<div className={css.hint}>web </div>
<div className={css.title}>{item.payload.questions.length} </div>
<JsonBlock label="问题内容" payload={item.payload.questions} />
</div>
)
})
@@ -11,7 +11,7 @@
* here.
*/
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PendingInteraction, PendingWait, PermissionSelect, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -113,6 +113,10 @@ export interface ConversationInjected {
stop(): void
/** Navigate to another session (breadcrumb ancestors). */
open(id: SessionId): void
/** Read the permission select (options + effective current value); null hides the control. */
permissions(): Promise<PermissionSelect | null>
/** Switch the permission preset; resolves the confirmed value, or null on failure (caller keeps the old value). */
setPermission(value: string): Promise<string | null>
}
/**
@@ -132,6 +136,68 @@ export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
& PropsStore<ChatStore> & ConversationInjected
/** The pending approval carrier the owner dispatches into the composer chain. */
export type ApprovalWait = PendingWait<'approval'>
/**
* Approval domain face over the carrier (the ui-question PendingQuestion
* pattern): render identity and question material forwarded transparently;
* answer owns the wire encoding — the ApprovalResponsePayload value shape
* with the audit correlation the host reconciles — and turns a rejected
* carrier receipt into a thrown error. Minted per carrier via useMemo.
*/
export class PendingApproval {
/**
* @param wait - the runtime carrier for one pending approval question.
*/
constructor(private readonly wait: ApprovalWait) {}
/** Opaque render identity (React key / one-shot latch remount axis), forwarded from the carrier. */
get key(): string {
return this.wait.key
}
/** The tool the question is about (headline fallback), forwarded from the carrier payload. */
get toolName(): string {
return this.wait.payload.toolName
}
/** The asker's human-readable WHY (headline when present), forwarded from the carrier payload. */
get reason(): string | undefined {
return this.wait.payload.reason
}
/** The paired tool call's id when the ask names one (command-line lookup key), forwarded from the carrier payload. */
get callId(): string | undefined {
return this.wait.payload.callId
}
/**
* Deliver the user's decision; a rejected carrier receipt throws. Panel
* removal stays frame-driven: the broadcast `approval/resolved` settles the
* wait and drops it from the pending list.
* @param outcome - the only two client-answerable outcomes.
*/
async answer(outcome: 'allowed-once' | 'rejected'): Promise<void> {
const receipt = await this.wait.respond({
ok: true,
value: { sessionId: this.wait.sessionId, approvalId: this.wait.payload.approvalId, outcome },
})
if (!receipt.accepted) {
throw new Error(`approval response rejected: ${receipt.reason}`)
}
}
}
/**
* Full approval-composer props: the framework runtime share (chain currency +
* session/global standard kit) plus the chain `matched` share — the entry's
* selector result, already narrowed to the approval carrier. No injected
* share: the carrier plus the domain face above carry the whole behavior
* surface; the paired command line derives from useSession in-component.
*/
export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait }
/**
* Injected share of the chat view entry: the two callbacks whose targets live
* outside the view (layout orchestration; the session object layer).
@@ -0,0 +1,107 @@
/* Composer-takeover approval panel (draft approval.png): the same floating
capsule footprint as the InputBar card, with an amber header band, the
justification headline, a muted command line, and right-aligned actions.
Warn semantics ride the alias state tokens; no hardcoded colors. */
/* Mirrors InputBar .root so the takeover is a content swap, not a layout jump. */
.root {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 32px 12px;
}
.card {
overflow: hidden;
width: 100%;
max-width: 776px;
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
}
/* Tinted full-width header band. */
.strip {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-primary);
font-size: 13px;
line-height: 18px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dsw-alias-state-warn-primary);
}
.body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 16px 14px;
}
/* The model's justification is the panel's message, not a footnote. */
.headline {
color: var(--dsw-alias-label-primary);
font-size: 15px;
font-weight: 500;
line-height: 24px;
}
.command {
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 13px;
line-height: 20px;
word-break: break-all;
}
.actionRow {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
.allow,
.reject {
padding: 6px 16px;
border-radius: 10px;
font-size: 13px;
line-height: 18px;
cursor: pointer;
}
.allow:disabled,
.reject:disabled {
opacity: 0.5;
cursor: default;
}
/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped
always-allow button). */
.allow {
border: none;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-label-primary-foreground);
}
/* Secondary: quiet outline. */
.reject {
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
background: transparent;
color: var(--dsw-alias-label-secondary);
}
.reject:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
border-color: transparent;
}
@@ -0,0 +1,71 @@
// ApprovalPanel: the composer-takeover approval prompt (designer draft
// approval.png), registered as a selector-routed entry of the
// conversation-declared composer chain. While an approval question is
// pending, this panel occupies the composer slot in place of the InputBar:
// an amber "Waiting for approval" strip on the card top, the model's
// justification as the headline, the paired command in muted code text, and
// a right-aligned refuse/allow action row. One-shot: the buttons disable
// after a click and the panel leaves (the InputBar returns) on the broadcast
// resolved frame. The draft's "Always allow this type" is deferred with
// grant storage.
import { useMemo, useState } from 'react'
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
import css from './ApprovalPanel.module.css'
/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */
export function commandOf(call: RunningToolCall | undefined): string | undefined {
if (call === undefined) return undefined
try {
const args = JSON.parse(call.argsRaw) as Record<string, unknown>
return typeof args.command === 'string' ? args.command : undefined
} catch {
// Unparseable model args: the panel still renders, just without the command line.
return undefined
}
}
/**
* Composer takeover boundary: mints the domain face on the carrier's stable
* identity and remounts the flow per request key, so the one-shot answered
* latch never leaks to the next pending approval.
* @param props - the selector-matched pending approval carrier plus the framework standard kit.
* @returns The approval prompt for this request.
*/
export function ApprovalPanel(props: ApprovalComposerProps) {
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
const command = props.useSession(s => commandOf(
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
return <ApprovalFlow key={approval.key} pending={approval} {...command === undefined ? {} : { command }} />
}
function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) {
// Local one-shot latch: the panel leaves only when the resolved frame
// lands; until then the buttons must not re-fire. An answer failure
// (rejected receipt / transport) re-arms them for retry.
const [answered, setAnswered] = useState(false)
const answer = (outcome: 'allowed-once' | 'rejected'): void => {
setAnswered(true)
void pending.answer(outcome).catch(() => { setAnswered(false) })
}
return (
<div className={css.root} data-approval-key={pending.key}>
<div className={css.card}>
<div className={css.strip}><span className={css.dot} /></div>
<div className={css.body}>
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
{command !== undefined && <div className={css.command}>{command}</div>}
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
</button>
</div>
</div>
</div>
</div>
)
}
@@ -15,6 +15,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d
import type { ConversationSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './ConversationRoot.module.css'
/** Full props = the automatic shares & injected share — composed by reference
@@ -38,7 +39,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
export function ConversationRoot({
sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain,
views, send, stop, open,
views, send, stop, open, permissions, setPermission,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
@@ -68,6 +69,7 @@ export function ConversationRoot({
disabled={removed}
error={error}
variant="composer"
controls={<PermissionSelect permissions={permissions} setPermission={setPermission} />}
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onStop={stop}
@@ -30,6 +30,8 @@ export interface InputBarProps {
placeholder?: string
/** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */
accessory?: ReactNode
/** Host-wired access-mode control (the composer mounts the permission chip here); replaces the visual-only placeholder. */
controls?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
@@ -56,7 +58,7 @@ const MODEL_OPTIONS: readonly SelectOption[] = [
]
export function InputBar({
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
draft, running, disabled, error, variant, placeholder, accessory, controls, onDraftChange, onSend, onStop,
}: InputBarProps) {
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
@@ -177,7 +179,8 @@ export function InputBar({
</button>
<div className={css.modes}>
{renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)}
{renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
{/* The wired permission chip supersedes the visual-only Access placeholder. */}
{controls ?? renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
</div>
</div>
<div className={css.trailing}>
@@ -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;
}
@@ -0,0 +1,88 @@
// PermissionSelect: the composer bottom-row permission chip (draft
// start.jpeg's `Read-only ` control). Options and the current value load on
// mount from the injected permissions() callback; empty options
// (permission-less host composition) render nothing. The visible chip is
// presentation only — an invisible native select stretched over it owns the
// menu and interaction. A switch disables the control until the host
// confirms, then adopts the confirmed value (`custom` is shown as the current
// value but never offered as a target — the host already omits it from
// switchable options; a stale-select failure restores the previous value).
import { useEffect, useRef, useState } from 'react'
import type { PermissionSelect as PermissionSelectData } from '@deepseek-ai/dsh-client-runtime/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 {
/** Read the select material; null hides the control. */
permissions: () => Promise<PermissionSelectData | null>
/** Switch the preset; resolves the confirmed value, or null on failure. */
setPermission: (value: string) => Promise<string | null>
}
export function PermissionSelect({ permissions, setPermission }: PermissionSelectProps) {
const [data, setData] = useState<PermissionSelectData | null>(null)
const [switching, setSwitching] = useState(false)
// Unmount guard: the load/switch promises outlive a session switch's remount.
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
void permissions().then((loaded) => {
if (aliveRef.current) setData(loaded)
})
return () => {
aliveRef.current = false
}
}, [permissions])
if (data === null) return null
const onChange = (value: string): void => {
if (value === data.currentValue) return
setSwitching(true)
const previous = data
setData({ ...data, currentValue: value })
void setPermission(value).then((confirmed) => {
if (!aliveRef.current) return
setSwitching(false)
if (confirmed === null) setData(previous)
else setData({ ...previous, currentValue: confirmed })
})
}
const current = data.options.find(option => option.value === data.currentValue)
return (
<label className={css.root} title={current?.description}>
<span className={css.chip}>
{displayName(current?.name ?? data.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="权限策略"
value={data.currentValue}
disabled={switching}
onChange={(e) => { onChange(e.target.value) }}
>
{data.options.map(option => (
<option key={option.value} value={option.value} disabled={option.value === 'custom'}>
{displayName(option.name)}
</option>
))}
</select>
</label>
)
}