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

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