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

View File

@@ -7,7 +7,7 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -299,10 +299,19 @@ export function createFixtureApi(): ApiProxy {
let nextSession = 1
let nextRpc = 1
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
/** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */
const pendingApprovalRpcId = mint()
const pendingApprovalId = 'fx-approval-1' as Extract<MuxFrame, { type: 'approval/requested' }>['approvalId']
/** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */
let approvalPending = true
const pendingQuestionRpcId = mint()
let questionPending = true
/** Per-session permission preset (fixture mirror of the host permission select). */
const permissionValues = new Map<SessionId, string>()
const PERMISSION_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.' },
]
const fixtureQuestions: Extract<MuxFrame, { type: 'question/requested' }>['questions'] = [
{
id: 'harness-profile',
@@ -522,6 +531,24 @@ export function createFixtureApi(): ApiProxy {
}
return ok(request, { accepted: true as const })
},
permissions: (request) => {
const { sessionId: id } = request.payload
if (summaryOf(id) === undefined) {
return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
}
return ok(request, { options: PERMISSION_OPTIONS, currentValue: permissionValues.get(id) ?? 'workspace-write' })
},
setPermission: (request) => {
const { sessionId: id, value } = request.payload
if (summaryOf(id) === undefined) {
return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } })
}
if (!PERMISSION_OPTIONS.some(option => option.value === value)) {
return err(request, { code: 'bad-request', message: `unknown permission value ${JSON.stringify(value)}`, details: { issues: [] } })
}
permissionValues.set(id, value)
return ok(request, { currentValue: value })
},
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }),
@@ -539,14 +566,16 @@ export function createFixtureApi(): ApiProxy {
const title = titleFrameOf(s.sessionId, logs.get(s.sessionId) ?? [])
if (title !== undefined) conn.push({ rpcId: mint(), payload: title })
}
conn.push({
rpcId: pendingApprovalRpcId,
payload: {
type: 'approval/requested', sessionId: sid('fx-alpha'),
approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract<MuxFrame, { type: 'approval/requested' }>['approvalId'],
toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)',
},
})
if (approvalPending) {
conn.push({
rpcId: pendingApprovalRpcId,
payload: {
type: 'approval/requested', sessionId: sid('fx-alpha'),
approvalId: pendingApprovalId,
toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)',
},
})
}
if (questionPending) {
conn.push({
rpcId: pendingQuestionRpcId,
@@ -584,6 +613,19 @@ export function createFixtureApi(): ApiProxy {
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Same routing discipline as the host: rpcId first, then the payload's
// audit correlation; a settled or unknown id is not-pending.
if (message.rpcId === pendingApprovalRpcId) {
if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
const value = message.result.value as { approvalId?: unknown; outcome?: unknown }
if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
approvalPending = false
emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome })
return Promise.resolve({ accepted: true })
}
if (!questionPending || message.rpcId !== pendingQuestionRpcId) {
return Promise.resolve({ accepted: false, reason: 'not-pending' })
}
@@ -633,6 +675,8 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.history': return this.api.sessions.history(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'session.permissions': return this.api.sessions.permissions(request)
case 'session.setPermission': return this.api.sessions.setPermission(request)
case 'host.describe': return this.api.host.describe(request)
}
}

View File

@@ -15,7 +15,7 @@ import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView,
ToolCallView, ToolResultView,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,

View File

@@ -49,6 +49,12 @@ export class FakeApiClient implements IApiClient {
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onPermissions: (payload: unknown) =>
Promise<RpcResponse<{ options: { value: string; name: string; description?: string }[]; currentValue: string }>> =
() => Promise.resolve(ok({ options: [], currentValue: 'custom' }))
onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise<RpcResponse<{ currentValue: string }>> =
payload => Promise.resolve(ok({ currentValue: payload.value }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
@@ -65,6 +71,8 @@ export class FakeApiClient implements IApiClient {
this.record('session.history', payload, this.onHistory(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)),
setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)),
}
readonly host: IApiClient['host'] = {

View File

@@ -255,6 +255,61 @@ describe('createFixtureApi', () => {
})).toEqual({ accepted: true })
})
it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => {
const api = createFixtureApi()
// Discover the resident approval's stable rpcId from the mux baseline.
const abort = new AbortController()
const seen: { rpcId: string; frame: MuxFrame }[] = []
const consuming = (async () => {
for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload })
})()
await vi.waitFor(() => {
expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true)
})
const requested = seen.find(s => s.frame.type === 'approval/requested')
if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable')
const approvalId = requested.frame.approvalId
// Routed but malformed answers.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } }))
.toEqual({ accepted: false, reason: 'bad-response' })
// The real answer settles the question and broadcasts resolved.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } }))
.toEqual({ accepted: true })
await vi.waitFor(() => {
expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true)
})
// Settled: a duplicate answer is late, and a fresh mux open replays nothing.
expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } }))
.toEqual({ accepted: false, reason: 'not-pending' })
abort.abort()
await consuming
const abort2 = new AbortController()
const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2)
expect(replayed.some(f => f.type === 'approval/requested')).toBe(false)
})
it('permissions/setPermission mirror the host select: read, switch, validation', async () => {
const api = createFixtureApi()
const read = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') }))
expect(read.result).toMatchObject({ ok: true, value: { currentValue: 'workspace-write' } })
const switched = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'danger-full-access' }))
expect(switched.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } })
const reread = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') }))
expect(reread.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } })
// Validation: ghost session and unknown value.
const ghostRead = await api.sessions.permissions(req({ sessionId: sid('fx-ghost') }))
expect(ghostRead.result.ok).toBe(false)
const ghostSwitch = await api.sessions.setPermission(req({ sessionId: sid('fx-ghost'), value: 'workspace-write' }))
expect(ghostSwitch.result.ok).toBe(false)
const unknown = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'nope' }))
expect(unknown.result.ok).toBe(false)
})
it('describe answers the fixture identity', async () => {
const api = createFixtureApi()
const response = await api.host.describe(req({}))
@@ -345,6 +400,8 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true)
expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true)
expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true)
expect((await client.sessions.permissions({ sessionId: id })).result.ok).toBe(true)
expect((await client.sessions.setPermission({ sessionId: id, value: 'danger-full-access' })).result.ok).toBe(true)
expect((await client.host.describe({})).result.ok).toBe(true)
})

View File

@@ -38,7 +38,15 @@ export type {
// PendingWait is a value export: tests construct fixture waits directly.
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
export type { PermissionOption, SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** The permission select material as the object layer serves it to UI plugins. */
export interface PermissionSelect {
/** Switchable presets plus (when derived) the current-only `custom`. */
options: { value: string; name: string; description?: string }[]
/** The effective current value (`custom` when knobs match no preset). */
currentValue: string
}
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
// ui-slots/web-react stay generic and dependency-inverted; the client-tree

View File

@@ -9,7 +9,7 @@ export interface TitledSessionSummary extends SessionSummary {
title?: string
}
/** One flattened session-list row (summary + lineage indent depth). */
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
export interface SessionListEntry {
sessionId: SessionId
title?: string
@@ -17,6 +17,8 @@ export interface SessionListEntry {
running: boolean
parentSessionId?: SessionId
cwd?: string
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
waitingApproval: boolean
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number
}
@@ -25,9 +27,10 @@ export interface SessionListEntry {
* summaries -> flat list with lineage indentation (pure; roots by updatedAt
* desc, DFS children in the same order, orphans degrade to roots).
* @param summaries - the host's session.list items.
* @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false).
* @returns display rows in render order.
*/
export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] {
export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet<SessionId>): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
@@ -54,7 +57,7 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess
return
}
visited.add(s.sessionId)
out.push({ ...s, depth })
out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth })
const kids = children.get(s.sessionId)
if (kids === undefined) return
kids.sort(byUpdatedDesc)

View File

@@ -36,6 +36,11 @@ export class SessionManager {
* drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these
* frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */
private readonly pendingBuffers = new Map<SessionId, RpcRequest<MuxFrame>[]>()
/** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open
* replays of the same requested frame). Manager-owned rather than read off Session instances
* because the sidebar must light up for sessions never instantiated. Cleared per connection
* generation — the reopen replay re-adds still-pending questions — and on session-removed. */
private readonly waitingApprovals = new Map<SessionId, Set<string>>()
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
private summaries: SessionSummary[] = []
private listState: 'idle' | 'loading' | 'error' = 'idle'
@@ -186,6 +191,22 @@ export class SessionManager {
this.notifier.markDirty()
}
}
// List-level waiting-approval bit (the sidebar amber dot): tracked here for
// every session, instantiated or not; approvalId keys make replays idempotent.
if (frame.type === 'approval/requested') {
let ids = this.waitingApprovals.get(frame.sessionId)
if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set())
if (!ids.has(frame.approvalId)) {
ids.add(frame.approvalId)
this.notifier.markDirty()
}
} else if (frame.type === 'approval/resolved') {
const ids = this.waitingApprovals.get(frame.sessionId)
if (ids !== undefined && ids.delete(frame.approvalId)) {
if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId)
this.notifier.markDirty()
}
}
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question frames never hit history: buffer for replay on instantiation;
@@ -232,6 +253,7 @@ export class SessionManager {
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
return
@@ -254,6 +276,12 @@ export class SessionManager {
/** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */
handleConnected(): void {
// Approvals resolved while disconnected send no frame: drop the bits and
// let the mux-open replay re-add every still-pending question.
if (this.waitingApprovals.size > 0) {
this.waitingApprovals.clear()
this.notifier.markDirty()
}
void this.refreshList()
for (const session of this.sessions.values()) void session.resync()
}
@@ -265,13 +293,14 @@ export class SessionManager {
? summary
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
})
const fresh = flattenLineage(merged)
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.title === entry.title && prev.depth === entry.depth
&& prev.waitingApproval === entry.waitingApproval
) return prev
this.entryCache.set(entry.sessionId, entry)
return entry

View File

@@ -32,6 +32,8 @@ export interface SessionSummary {
cwd?: string
parentId?: SessionId
running: boolean
/** An approval question is pending on this session (sidebar amber-dot state). */
waitingApproval: boolean
updatedAt: number
}
@@ -310,6 +312,7 @@ export class SessionsService {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
waitingApproval: entry.waitingApproval,
updatedAt: entry.updatedAt,
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),

View File

@@ -128,6 +128,31 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/**
* Read the permission select (options + effective current value).
* @returns the select material, or the error branch on failure.
*/
async permissions(): Promise<RpcResult<{ options: { value: string; name: string; description?: string }[]; currentValue: string }>> {
try {
return (await this.api.sessions.permissions({ sessionId: this.sessionId })).result
} catch (error) {
return transportError(error)
}
}
/**
* Switch the permission preset.
* @param value - a preset value advertised by {@link Session.permissions} (never `custom`).
* @returns the confirmed current value, or the error branch on failure.
*/
async setPermission(value: string): Promise<RpcResult<{ currentValue: string }>> {
try {
return (await this.api.sessions.setPermission({ sessionId: this.sessionId, value })).result
} catch (error) {
return transportError(error)
}
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()

View File

@@ -52,6 +52,13 @@ export class FakeApiClient implements IApiClient {
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onPermissions: (payload: unknown) =>
Promise<RpcResponse<{ options: { value: string; name: string; description?: string }[]; currentValue: string }>> =
() => Promise.resolve(ok({ options: [], currentValue: 'custom' }))
onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise<RpcResponse<{ currentValue: string }>> =
payload => Promise.resolve(ok({ currentValue: payload.value }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
@@ -68,6 +75,8 @@ export class FakeApiClient implements IApiClient {
this.record('session.history', payload, this.onHistory(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)),
setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)),
}
readonly host: IApiClient['host'] = {

View File

@@ -281,3 +281,42 @@ describe('connected generation', () => {
})
})
})
describe('waiting-approval list bit', () => {
it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
// Mux-open replay of the same question (same approvalId) is idempotent.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
})
it('clears only when the last outstanding question resolves; session-removed drops the bit', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } })
manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
// Removed sessions drop their bit outright.
manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } })
manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
expect(manager.getListSnapshot().items).toHaveLength(0)
})
it('drops stale bits on reconnect — the reopen replay re-adds still-pending questions', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } })
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
manager.handleConnected() // resolved-while-disconnected questions send no frame
expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
})
})

View File

@@ -669,3 +669,27 @@ describe('reference stability (the memo contract)', () => {
expect(resolved.pending).toBe(after.pending)
})
})
describe('permissions / setPermission', () => {
it('passes the select read and switch through with the session id', async () => {
const { api, session } = makeSession()
api.onPermissions = () => Promise.resolve(ok({ options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' }))
const read = await session.permissions()
expect(read.ok).toBe(true)
if (read.ok) expect(read.value.currentValue).toBe('workspace-write')
expect(api.callsOf('session.permissions')).toMatchObject([{ sessionId: SID }])
const switched = await session.setPermission('danger-full-access')
expect(switched.ok).toBe(true)
if (switched.ok) expect(switched.value.currentValue).toBe('danger-full-access')
expect(api.callsOf('session.setPermission')).toMatchObject([{ sessionId: SID, value: 'danger-full-access' }])
})
it('folds transport failures into the error branch', async () => {
const { api, session } = makeSession()
api.onPermissions = () => Promise.reject(new Error('down'))
api.onSetPermission = () => Promise.reject(new Error('down'))
expect((await session.permissions()).ok).toBe(false)
expect((await session.setPermission('x')).ok).toBe(false)
})
})

View File

@@ -4,6 +4,8 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state: the session row's amber warning dot (a manager-tracked `waitingApproval` list bit, lit for uninstantiated sessions too) outranks the running ring until the question resolves. Question placeholders remain in the message flow as display-only PendingCards while ui-question owns the answering takeover. The composer's bottom-row chip mounts the permission-preset select (`PermissionSelect`), fed by the injected `permissions`/`setPermission` callbacks over the object layer's session RPCs; empty options (a permission-less host) hide the control, and the derived `custom` value renders as current-only.
Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
@@ -26,4 +28,5 @@ None; this package neither assembles nor sends a provider request.
- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
- **The permission select reads once per mount** — a host-side preset change from another client surfaces only after a session re-select; live knob-event-driven refresh is deferred.
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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).

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -61,6 +61,10 @@ async function bench() {
() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
permissions: vi.fn<() => Promise<{ ok: boolean; value?: { options: { value: string; name: string }[]; currentValue: string }; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } })),
setPermission: vi.fn<() => Promise<{ ok: boolean; value?: { currentValue: string }; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { currentValue: 'danger-full-access' } })),
}
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
@@ -143,6 +147,32 @@ describe('conversation slot inject surface', () => {
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
})
it('permissions/setPermission thread the object layer; empty options and failures fold to null', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
expect(await injected.permissions()).toMatchObject({ currentValue: 'workspace-write' })
expect(await injected.setPermission('danger-full-access')).toBe('danger-full-access')
// Empty options (permission-less host) and the error branch both hide the control.
b.sessionFake.permissions.mockResolvedValueOnce({ ok: true, value: { options: [], currentValue: 'custom' } })
expect(await injected.permissions()).toBeNull()
b.sessionFake.permissions.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
expect(await injected.permissions()).toBeNull()
b.sessionFake.setPermission.mockResolvedValueOnce({ ok: false, error: { code: 'bad-request', message: 'x' } })
expect(await injected.setPermission('nope')).toBeNull()
})
it('registers the approval takeover on the composer chain: routing selector, no inject face', async () => {
const b = await bench()
const entry = b.slots.entries('conversation.composer')[0]!
expect(entry.inject).toBeUndefined()
// The selector narrows the chain currency: approval wait in → that wait; none → null.
const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown
const approval = { kind: 'approval' }
expect(select({ interactions: [{ kind: 'question' }, approval] })).toBe(approval)
expect(select({ interactions: [{ kind: 'question' }] })).toBeNull()
expect(select({ interactions: [] })).toBeNull()
})
it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => {
const b = await bench()
const { instance, injected } = b.conversationSurface(ROOT)

View File

@@ -44,11 +44,11 @@ describe('MessageItem arms', () => {
})
describe('small branch tails', () => {
it('PendingCard approval reason renders when present', () => {
it('PendingCard renders the question count', () => {
const view = render(
<PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { approvalId: 'a1', toolName: 'rm', reason: 'careful' } as PendingWait<'approval'>['payload'], vi.fn())} />,
<PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{ id: 'q1', question: '选择' }] } as PendingWait<'question'>['payload'], vi.fn())} />,
)
expect(view.getByText('careful')).toBeTruthy()
expect(view.getByText(/等待回答1 题)/)).toBeTruthy()
})
it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => {

View File

@@ -123,8 +123,8 @@ describe('bash sample row', () => {
return createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, updatedAt: 0 },
},
current: undefined,
} as SessionListState)
@@ -158,7 +158,7 @@ describe('bash sample row', () => {
const orphan = 'late-child' as SessionId
store.update((d) => {
d.ids.push(orphan)
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 }
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, updatedAt: 0 }
})
const view = render(<BashRow {...rowProps(orphan, { store })} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()

View File

@@ -75,7 +75,14 @@ async function bench(nodes: ToolResultNode[]) {
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('sessions', {
list,
manager: { get: () => ({ loadOlder: vi.fn() }) },
manager: {
get: () => ({
loadOlder: vi.fn(),
// The mounted PermissionSelect loads on mount; empty options hide the control.
permissions: vi.fn(() => Promise.resolve({ ok: true, value: { options: [], currentValue: 'custom' } })),
setPermission: vi.fn(() => Promise.resolve({ ok: false, error: { code: 'bad-request', message: 'unused', details: { issues: [] } } })),
}),
},
scope: () => ({ get: () => scoped }),
cell: (id: string) => (id === SID ? cell : undefined),
create: vi.fn(),

View File

@@ -342,12 +342,17 @@ describe('ChatView', () => {
expect(lv.getByText('载入历史…')).toBeTruthy()
})
it('pending interactions render placeholder cards', () => {
it('question waits render placeholder cards; approvals leave the flow (composer takeover)', () => {
const h = makeHarness({
pending: [new PendingWait('approval', RpcId('r1'), SID,
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())],
pending: [
new PendingWait('approval', RpcId('r1'), SID,
{ approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()),
new PendingWait('question', RpcId('r2'), SID,
{ questions: [{ id: 'q1', question: '选择' }] } as PendingWait<'question'>['payload'], vi.fn()),
],
})
const view = render(<h.ChatView {...h.props} />)
expect(view.getByText(/等待审批/)).toBeTruthy()
expect(view.getByText(/等待回答1 题)/)).toBeTruthy()
expect(view.queryByText(/等待审批/)).toBeNull()
})
})

View File

@@ -82,6 +82,8 @@ describe('ConversationRoot branches', () => {
send={vi.fn()}
stop={vi.fn()}
open={open}
permissions={() => Promise.resolve(null)}
setPermission={() => Promise.resolve(null)}
/>,
)
return { view, open, chat }
@@ -141,6 +143,8 @@ describe('ConversationRoot branches', () => {
send={vi.fn()}
stop={vi.fn()}
open={vi.fn()}
permissions={() => Promise.resolve(null)}
setPermission={() => Promise.resolve(null)}
/>,
)
expect(view.getByTestId('view-body')).toBeTruthy()

View File

@@ -221,6 +221,8 @@ describe('ConversationRoot', () => {
send={send}
stop={stop}
open={open}
permissions={() => Promise.resolve(null)}
setPermission={() => Promise.resolve(null)}
/>)
return { ui, chat, send, stop, open, renderSlot }
}

View File

@@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired.
- **State dots have three live data states (running/amber approval-waiting/none)** — the done/error sources arrive with notifications; the four-color primitive is already wired.
- **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred.
- **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host.

View File

@@ -102,7 +102,11 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
</button>
)
: <span className={css.slot} />}
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
{/* Waiting-approval (amber) outranks the running ring: the session is
blocked on the user, which is the more actionable fact. */}
<span className={css.slot}>
{row.waitingApproval ? <StateDot state="warning" /> : row.running && <StateDot state="ongoing" />}
</span>
<span className={css.title}>{row.title}</span>
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
<span className={css.rowActions}>

View File

@@ -38,6 +38,8 @@ export interface SessionRow {
hasChildren: boolean
expanded: boolean
running: boolean
/** An approval question is pending (amber warning dot outranks the running ring). */
waitingApproval: boolean
updatedAt: number
}
@@ -159,6 +161,7 @@ function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boo
hasChildren,
expanded,
running: s.running,
waitingApproval: s.waitingApproval,
updatedAt: s.updatedAt,
}
}

View File

@@ -23,7 +23,7 @@ async function bench() {
await ctx.plugin(SlotsService).await()
const list = createSnapshotStore<SessionListState>({
ids: [sid('a')],
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, waitingApproval: false, updatedAt: 1 } },
current: undefined,
})
const sessions = {

View File

@@ -31,6 +31,7 @@ interface SummaryInit {
cwd?: string
parentId?: string
running?: boolean
waitingApproval?: boolean
updatedAt?: number
}
@@ -40,6 +41,7 @@ function summary(init: SummaryInit): SessionSummary {
title: init.title ?? init.id,
displayTitle: init.title ?? init.id,
running: init.running ?? false,
waitingApproval: init.waitingApproval ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.cwd !== undefined) s.cwd = init.cwd
@@ -290,4 +292,17 @@ describe('SidebarRoot', () => {
expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy()
expect(idleRow.querySelector('[data-state="ongoing"]')).toBeNull()
})
it('waiting-approval shows the amber warning dot and outranks the running ring', () => {
mount(
summary({ id: 'blocked', title: 'blocked one', cwd: '/p', running: true, waitingApproval: true, updatedAt: 2 }),
summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 1 }),
)
act(() => { fireEvent.click(screen.getByText('p')) })
const blockedRow = screen.getByText('blocked one').closest('[role="treeitem"]')!
const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')!
expect(blockedRow.querySelector('[data-state="warning"]')).toBeTruthy()
expect(blockedRow.querySelector('[data-state="ongoing"]')).toBeNull()
expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy()
})
})

View File

@@ -15,6 +15,7 @@ interface SummaryInit {
cwd?: string
parentId?: string
running?: boolean
waitingApproval?: boolean
updatedAt?: number
}
@@ -23,6 +24,7 @@ function summary(init: SummaryInit): SessionSummary {
id: sid(init.id),
displayTitle: init.displayTitle ?? init.title ?? init.id,
running: init.running ?? false,
waitingApproval: init.waitingApproval ?? false,
updatedAt: init.updatedAt ?? 0,
}
if (init.title !== undefined) s.title = init.title

View File

@@ -144,6 +144,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
send={vi.fn()}
stop={vi.fn()}
open={vi.fn()}
permissions={() => Promise.resolve(null)}
setPermission={() => Promise.resolve(null)}
/>,
)
}