diff --git a/console/web/src/components/chat/AutoAcceptToggle.tsx b/console/web/src/components/chat/AutoAcceptToggle.tsx
deleted file mode 100644
index 55cd6550..00000000
--- a/console/web/src/components/chat/AutoAcceptToggle.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { useId } from 'react'
-import { cn } from '@/lib/utils'
-
-interface AutoAcceptToggleProps {
- value: boolean
- onChange: (next: boolean) => void
- disabled?: boolean
- className?: string
-}
-
-/**
- * Per-conversation toggle that flips the auto-accept-all-approvals
- * mode. Lives in the composer's bottom bar next to the mode picker.
- *
- * The label is rendered verbatim ("auto-accept: on" / "auto-accept:
- * off") rather than as an icon because this is a foot-gun: when ON,
- * every safe `agent_trigger` from the model is auto-resolved without a
- * human click (state-mutating, destructive, and egress calls remain
- * gated client-side by `auto-accept-policy.ts`). The user needs to
- * read the state at a glance.
- *
- * Accessibility:
- * - `role="switch"` + `aria-checked` carry the binary state to SRs;
- * the visible "on"/"off" text matches the announced state so the
- * "label in name" requirement is satisfied.
- * - `focus-visible` rings the control for keyboard navigation.
- * - The explanatory text lives in a visually-hidden node and is
- * wired in via `aria-describedby` so keyboard/SR users get the
- * same context that mouse users get from the `title` tooltip.
- * - `aria-disabled` rather than `disabled` so the control stays in
- * the tab order and an SR can still read the current policy
- * while streaming.
- *
- * Contrast: the ON state uses `bg-ink text-bg` (light theme:
- * near-black on near-white, ~16:1; dark theme: near-white on
- * near-black, similar) rather than `bg-accent text-bg`, which
- * measured ~3.2:1 on light bg and failed WCAG 1.4.3.
- */
-export function AutoAcceptToggle({
- value,
- onChange,
- disabled,
- className,
-}: AutoAcceptToggleProps) {
- const descId = useId()
- const interactive = !disabled
-
- const handleClick = () => {
- if (!interactive) return
- onChange(!value)
- }
-
- return (
- <>
-
-
- {value
- ? 'Auto-accept is on. Approval prompts for safe calls (reads, lookups, listings) are resolved automatically. Destructive or state-mutating calls still require a click.'
- : 'Auto-accept is off. Every approval prompt waits for an explicit click.'}
-
- >
- )
-}
diff --git a/console/web/src/components/chat/ChatPanel.tsx b/console/web/src/components/chat/ChatPanel.tsx
index 0bb09122..655f2c08 100644
--- a/console/web/src/components/chat/ChatPanel.tsx
+++ b/console/web/src/components/chat/ChatPanel.tsx
@@ -41,7 +41,6 @@ export function ChatPanel({ density = 'route' }: ChatPanelProps) {
remove,
setModel,
setMode,
- setAutoAccept,
appendMessage,
updateMessage,
compactConversation,
@@ -85,7 +84,6 @@ export function ChatPanel({ density = 'route' }: ChatPanelProps) {
density={density}
onUpdateModel={setModel}
onUpdateMode={setMode}
- onUpdateAutoAccept={setAutoAccept}
onAppendMessage={appendMessage}
onPatchMessage={updateMessage}
onCompactConversation={compactConversation}
diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx
index d29ffa46..05a6e112 100644
--- a/console/web/src/components/chat/ChatView.tsx
+++ b/console/web/src/components/chat/ChatView.tsx
@@ -1,8 +1,9 @@
import { Copy } from 'lucide-react'
import { useCallback, useMemo, useRef, useState } from 'react'
+import { FullPermissionsBanner } from '@/components/permissions/FullPermissionsBanner'
import { LiveRegion } from '@/components/ui/LiveRegion'
import { StatusDot } from '@/components/ui/StatusDot'
-import { useAutoAcceptApprovals } from '@/hooks/use-auto-accept-approvals'
+import { useApprovalSettings } from '@/hooks/use-approval-settings'
import { uid } from '@/hooks/use-conversations'
import { useFunctionsCatalog } from '@/hooks/use-functions-catalog'
import { useLiveAnnouncer } from '@/hooks/use-live-announcer'
@@ -74,7 +75,6 @@ interface ChatViewProps {
density?: 'route' | 'dock'
onUpdateModel: (id: string, model: ModelId) => void
onUpdateMode: (id: string, mode: Mode) => void
- onUpdateAutoAccept: (id: string, autoAccept: boolean) => void
onAppendMessage: (id: string, message: Message) => void
onPatchMessage: (id: string, messageId: string, patch: MessagePatch) => void
onCompactConversation: (id: string, marker: Message) => void
@@ -88,7 +88,6 @@ export function ChatView({
density = 'route',
onUpdateModel,
onUpdateMode,
- onUpdateAutoAccept,
onAppendMessage,
onPatchMessage,
onCompactConversation,
@@ -130,22 +129,21 @@ export function ChatView({
) => fn(sessionId, functionCallId, decision)
}, [backend])
- useAutoAcceptApprovals({
- conversationId: conversation.id,
- enabled: !!conversation.autoAccept,
- messages: conversation.messages,
- resolveApproval,
- onAccepted: (functionId) => {
- announcer.announce(`auto-accepted: ${functionId}`)
- },
- onDenied: (functionId) => {
- /* The policy refused — leave the card for manual click. Tell
- * SR users so they know to navigate to it. */
- announcer.announce(
- `auto-accept refused for ${functionId}: high-risk call requires manual approval`,
- )
- },
- })
+ const approvalSettings = useApprovalSettings(sessionId)
+
+ const handleAlwaysAllow = useMemo(() => {
+ const resolveFn = backend.resolveApproval
+ if (!resolveFn) return undefined
+ // "Approve always" is a per-session grant honored in every mode, so the
+ // button shows on every prompt (full mode never produces prompts, so
+ // it's moot there). Approves this call and stops asking for the same
+ // function for the rest of the conversation.
+ return async (sId: string, functionCallId: string, functionId: string) => {
+ await approvalSettings.approveAlways(functionId)
+ await resolveFn(sId, functionCallId, 'allow')
+ announcer.announce(`approved always this session: ${functionId}`)
+ }
+ }, [backend, approvalSettings, announcer])
const handleCopySessionId = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.clipboard) return
@@ -555,11 +553,18 @@ export function ChatView({
+ {approvalSettings.settings.mode === 'full' ? (
+ void approvalSettings.setMode('manual')}
+ />
+ ) : null}
+
@@ -571,11 +576,12 @@ export function ChatView({
modelOptions={modelOptions}
catalogLoading={catalogLoading}
functionEntries={functionEntries}
- autoAccept={conversation.autoAccept}
+ permissionMode={approvalSettings.settings.mode}
+ permissionModeLoading={!approvalSettings.loaded}
onModeChange={(next) => onUpdateMode(conversation.id, next)}
onModelChange={(next) => onUpdateModel(conversation.id, next)}
- onAutoAcceptChange={(next) =>
- onUpdateAutoAccept(conversation.id, next)
+ onPermissionModeChange={(next) =>
+ void approvalSettings.setMode(next)
}
onSubmit={handleSubmit}
onStop={handleStop}
diff --git a/console/web/src/components/chat/Composer.tsx b/console/web/src/components/chat/Composer.tsx
index 996d96e7..53e8cf1f 100644
--- a/console/web/src/components/chat/Composer.tsx
+++ b/console/web/src/components/chat/Composer.tsx
@@ -1,11 +1,12 @@
import type { LexicalEditor } from 'lexical'
import { useCallback, useRef, useState } from 'react'
+import { PermissionModePicker } from '@/components/permissions/PermissionModePicker'
import { Button } from '@/components/ui/Button'
+import type { PermissionMode } from '@/lib/backend/approval-settings'
import type { FunctionEntry } from '@/lib/functions'
import type { Attachment, Mode, ModelId, ModelOption } from '@/types/chat'
import { AttachmentButton } from './AttachmentButton'
import { AttachmentChip } from './AttachmentChip'
-import { AutoAcceptToggle } from './AutoAcceptToggle'
import { LexicalShell } from './LexicalShell'
import { ModelPicker } from './ModelPicker'
import { ModePicker } from './ModePicker'
@@ -21,15 +22,15 @@ interface ComposerProps {
modelOptions: ModelOption[]
catalogLoading?: boolean
/**
- * Per-conversation auto-accept-all-approvals flag. When true, the
- * chat client auto-resolves every pending approval that surfaces
- * for this conversation. Rendered as a sibling pill of the mode
- * picker.
+ * Per-conversation permission mode (manual / auto / full). Owned by
+ * the backend `approval_settings` scope; ChatView passes the loaded
+ * value here. While loading, the picker disables.
*/
- autoAccept: boolean
+ permissionMode: PermissionMode
+ permissionModeLoading?: boolean
onModeChange: (next: Mode) => void
onModelChange: (next: ModelId) => void
- onAutoAcceptChange: (next: boolean) => void
+ onPermissionModeChange: (next: PermissionMode) => void
onSubmit: (payload: ComposerSubmitPayload) => void
onStop?: () => void
isStreaming?: boolean
@@ -45,10 +46,11 @@ export function Composer({
model,
modelOptions,
catalogLoading,
- autoAccept,
+ permissionMode,
+ permissionModeLoading,
onModeChange,
onModelChange,
- onAutoAcceptChange,
+ onPermissionModeChange,
onSubmit,
onStop,
isStreaming,
@@ -111,10 +113,10 @@ export function Composer({
- Promise
+ onAlwaysAllow?: (
+ sessionId: string,
+ functionCallId: string,
+ functionId: string,
+ ) => Promise
}
/**
@@ -132,6 +137,7 @@ export function FunctionCallGroup({
messages,
defaultOpen,
onResolveApproval,
+ onAlwaysAllow,
}: FunctionCallGroupProps) {
const status = deriveStatus(messages)
const concerning = hasConcerningChild(messages)
@@ -190,18 +196,24 @@ export function FunctionCallGroup({
const functionCallId = m.functionCallId
let onApprove: (() => Promise) | undefined
let onDeny: (() => Promise) | undefined
+ let onAlwaysAllowHandler: (() => Promise) | undefined
if (onResolveApproval && sessionId && functionCallId) {
onApprove = () =>
onResolveApproval(sessionId, functionCallId, 'allow')
onDeny = () =>
onResolveApproval(sessionId, functionCallId, 'deny')
}
+ if (onAlwaysAllow && sessionId && functionCallId) {
+ onAlwaysAllowHandler = () =>
+ onAlwaysAllow(sessionId, functionCallId, m.functionId)
+ }
return (
)
diff --git a/console/web/src/components/chat/FunctionCallMessage.tsx b/console/web/src/components/chat/FunctionCallMessage.tsx
index 6199b608..4b564e4b 100644
--- a/console/web/src/components/chat/FunctionCallMessage.tsx
+++ b/console/web/src/components/chat/FunctionCallMessage.tsx
@@ -3,6 +3,7 @@ import {
SandboxFunctionIdLabel,
SandboxToolView,
} from '@/components/chat/sandbox'
+import { AlwaysAllowButton } from '@/components/permissions/AlwaysAllowButton'
import { Button } from '@/components/ui/Button'
import { StatusDot } from '@/components/ui/StatusDot'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs'
@@ -20,6 +21,12 @@ interface FunctionCallMessageProps {
*/
onApprove?: () => void | Promise
onDeny?: () => void | Promise
+ /**
+ * Approve + add to per-conversation always-allow list. When provided,
+ * an "always allow" button renders next to approve/deny. Destructive
+ * function ids gate on a confirmation modal inside the button.
+ */
+ onAlwaysAllow?: () => void | Promise
/**
* When true, render without the outer `border border-rule bg-bg` chrome
* so the parent (typically a `FunctionCallGroup`) can frame the stack.
@@ -84,13 +91,16 @@ export function FunctionCallMessage({
defaultOpen,
onApprove,
onDeny,
+ onAlwaysAllow,
embedded,
}: FunctionCallMessageProps) {
const pending = !!message.pendingApproval
const running = !!message.running
const [open, setOpen] = useState(!!defaultOpen || pending)
const [tab, setTab] = useState<'terminal' | 'json'>('terminal')
- const [submitting, setSubmitting] = useState<'approve' | 'deny' | null>(null)
+ const [submitting, setSubmitting] = useState<
+ 'approve' | 'deny' | 'always_allow' | null
+ >(null)
const [submitError, setSubmitError] = useState(null)
const sandboxPreview = SandboxToolView.tryRenderPreview(message)
@@ -101,8 +111,9 @@ export function FunctionCallMessage({
!(running && hasSandboxTerminal) &&
!(!pending && !running && hasSandboxTerminal)
- const runResolve = async (kind: 'approve' | 'deny') => {
- const handler = kind === 'approve' ? onApprove : onDeny
+ const runResolve = async (kind: 'approve' | 'deny' | 'always_allow') => {
+ const handler =
+ kind === 'approve' ? onApprove : kind === 'deny' ? onDeny : onAlwaysAllow
if (!handler || submitting) return
setSubmitError(null)
setSubmitting(kind)
@@ -235,6 +246,14 @@ export function FunctionCallMessage({
>
{submitting === 'deny' ? 'denying…' : 'deny'}
+ {onAlwaysAllow ? (
+ void runResolve('always_allow')}
+ disabled={!!submitting}
+ submitting={submitting === 'always_allow'}
+ />
+ ) : null}
{submitting ? (
waiting for the agent to resume…
diff --git a/console/web/src/components/chat/Message.tsx b/console/web/src/components/chat/Message.tsx
index c49fd878..3bd39dc8 100644
--- a/console/web/src/components/chat/Message.tsx
+++ b/console/web/src/components/chat/Message.tsx
@@ -19,9 +19,18 @@ interface MessageProps {
functionCallId: string,
decision: 'allow' | 'deny',
) => Promise
+ onAlwaysAllow?: (
+ sessionId: string,
+ functionCallId: string,
+ functionId: string,
+ ) => Promise
}
-export function Message({ message, onResolveApproval }: MessageProps) {
+export function Message({
+ message,
+ onResolveApproval,
+ onAlwaysAllow,
+}: MessageProps) {
switch (message.role) {
case 'user':
return
@@ -34,15 +43,21 @@ export function Message({ message, onResolveApproval }: MessageProps) {
const functionCallId = message.functionCallId
let onApprove: (() => Promise) | undefined
let onDeny: (() => Promise) | undefined
+ let onAlwaysAllowHandler: (() => Promise) | undefined
if (onResolveApproval && sessionId && functionCallId) {
onApprove = () => onResolveApproval(sessionId, functionCallId, 'allow')
onDeny = () => onResolveApproval(sessionId, functionCallId, 'deny')
}
+ if (onAlwaysAllow && sessionId && functionCallId) {
+ onAlwaysAllowHandler = () =>
+ onAlwaysAllow(sessionId, functionCallId, message.functionId)
+ }
return (
)
}
diff --git a/console/web/src/components/chat/MessageList.tsx b/console/web/src/components/chat/MessageList.tsx
index a4ed2597..408f9eb2 100644
--- a/console/web/src/components/chat/MessageList.tsx
+++ b/console/web/src/components/chat/MessageList.tsx
@@ -20,6 +20,11 @@ interface MessageListProps {
functionCallId: string,
decision: 'allow' | 'deny',
) => Promise
+ onAlwaysAllow?: (
+ sessionId: string,
+ functionCallId: string,
+ functionId: string,
+ ) => Promise
}
type RenderItem =
@@ -72,6 +77,7 @@ export function MessageList({
isThinking,
density = 'route',
onResolveApproval,
+ onAlwaysAllow,
}: MessageListProps) {
const bottomRef = useRef(null)
const containerRef = useRef(null)
@@ -146,12 +152,14 @@ export function MessageList({
key={item.key}
message={item.message}
onResolveApproval={onResolveApproval}
+ onAlwaysAllow={onAlwaysAllow}
/>
) : (
),
)}
diff --git a/console/web/src/components/permissions/AlwaysAllowButton.tsx b/console/web/src/components/permissions/AlwaysAllowButton.tsx
new file mode 100644
index 00000000..878bdbd5
--- /dev/null
+++ b/console/web/src/components/permissions/AlwaysAllowButton.tsx
@@ -0,0 +1,104 @@
+import { useState } from 'react'
+import { Button } from '@/components/ui/Button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+} from '@/components/ui/Dialog'
+import { isAutoAcceptable } from '@/lib/backend/auto-accept-policy'
+
+const isDestructiveFunction = (id: string) => !isAutoAcceptable(id)
+
+interface AlwaysAllowButtonProps {
+ functionId: string
+ onConfirm: () => void | Promise
+ disabled?: boolean
+ submitting?: boolean
+}
+
+/**
+ * Third button on a pending approval prompt: "approve always". Approves
+ * the current call AND remembers the decision for the rest of this
+ * session (per-conversation `approved_always`, honored in every mode),
+ * so the same function stops prompting. For functions whose id matches
+ * the potentially-destructive verb policy (write / delete / exec / send /
+ * credential / …) we gate on a confirmation dialog that shows the
+ * canonical `function_id` so the user can't misclick into a standing
+ * grant.
+ */
+export function AlwaysAllowButton({
+ functionId,
+ onConfirm,
+ disabled,
+ submitting,
+}: AlwaysAllowButtonProps) {
+ const [confirmOpen, setConfirmOpen] = useState(false)
+ const destructive = isDestructiveFunction(functionId)
+
+ function handleClick() {
+ if (destructive) {
+ setConfirmOpen(true)
+ return
+ }
+ void onConfirm()
+ }
+
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/console/web/src/components/permissions/DefaultPermissionModePicker.tsx b/console/web/src/components/permissions/DefaultPermissionModePicker.tsx
new file mode 100644
index 00000000..20df2391
--- /dev/null
+++ b/console/web/src/components/permissions/DefaultPermissionModePicker.tsx
@@ -0,0 +1,69 @@
+import { useState } from 'react'
+import { ModeToggle } from '@/components/ui/ModeToggle'
+import {
+ loadDefaultPermissionMode,
+ type PermissionMode,
+ saveDefaultPermissionMode,
+} from '@/lib/storage'
+import { FullModeConfirmDialog } from './FullModeConfirmDialog'
+
+interface DefaultPermissionModePickerProps {
+ /** Lifted state so a parent can react (banner, telemetry) without re-reading localStorage. */
+ value?: PermissionMode
+ onChange?: (next: PermissionMode) => void
+}
+
+/**
+ * User-level default mode applied only to NEW conversations. Existing
+ * conversations own their own mode independently after creation.
+ *
+ * Selecting Full opens a confirmation dialog before localStorage is
+ * written. Cancel keeps the previous value.
+ */
+export function DefaultPermissionModePicker({
+ value,
+ onChange,
+}: DefaultPermissionModePickerProps) {
+ const [internal, setInternal] = useState(() =>
+ value ?? loadDefaultPermissionMode(),
+ )
+ const [pendingFull, setPendingFull] = useState(false)
+ const current = value ?? internal
+
+ function commit(next: PermissionMode) {
+ saveDefaultPermissionMode(next)
+ setInternal(next)
+ onChange?.(next)
+ }
+
+ function handleSelect(next: PermissionMode) {
+ if (next === current) return
+ if (next === 'full') {
+ setPendingFull(true)
+ return
+ }
+ commit(next)
+ }
+
+ return (
+ <>
+
+ value={current}
+ onChange={handleSelect}
+ variant="radio"
+ aria-label="default permission mode"
+ options={[
+ { value: 'manual', label: 'manual' },
+ { value: 'auto', label: 'auto' },
+ { value: 'full', label: 'full' },
+ ]}
+ />
+ commit('full')}
+ scope="default"
+ />
+ >
+ )
+}
diff --git a/console/web/src/components/permissions/FullModeConfirmDialog.tsx b/console/web/src/components/permissions/FullModeConfirmDialog.tsx
new file mode 100644
index 00000000..dfc6c781
--- /dev/null
+++ b/console/web/src/components/permissions/FullModeConfirmDialog.tsx
@@ -0,0 +1,64 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+} from '@/components/ui/Dialog'
+
+interface FullModeConfirmDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onConfirm: () => void
+ /**
+ * Context-aware copy. The Configuration screen flow phrases it as
+ * "for every new conversation"; the in-chat picker phrases it as
+ * "for this conversation". The default works for the in-chat case.
+ */
+ scope?: 'conversation' | 'default'
+}
+
+export function FullModeConfirmDialog({
+ open,
+ onOpenChange,
+ onConfirm,
+ scope = 'conversation',
+}: FullModeConfirmDialogProps) {
+ const target =
+ scope === 'default' ? 'every new conversation' : 'this conversation'
+ return (
+
+ )
+}
diff --git a/console/web/src/components/permissions/FullPermissionsBanner.tsx b/console/web/src/components/permissions/FullPermissionsBanner.tsx
new file mode 100644
index 00000000..ae3af923
--- /dev/null
+++ b/console/web/src/components/permissions/FullPermissionsBanner.tsx
@@ -0,0 +1,38 @@
+interface FullPermissionsBannerProps {
+ onDisable: () => void
+}
+
+/**
+ * Persistent banner shown while a conversation is in `mode === 'full'`.
+ * Always visible; the only way to dismiss is by reverting to manual.
+ * Tone is intentionally loud — Full mode bypasses every safety prompt,
+ * including destructive verbs like `shell::exec` and `fs::write`.
+ */
+export function FullPermissionsBanner({
+ onDisable,
+}: FullPermissionsBannerProps) {
+ return (
+
+
+
+ full permissions active
+
+
+ the agent runs every function without asking — including writing
+ files, executing shells, and sending messages.
+
+
+
+
+ )
+}
diff --git a/console/web/src/components/permissions/FunctionAllowlistTree.tsx b/console/web/src/components/permissions/FunctionAllowlistTree.tsx
new file mode 100644
index 00000000..832d1e52
--- /dev/null
+++ b/console/web/src/components/permissions/FunctionAllowlistTree.tsx
@@ -0,0 +1,378 @@
+import { useMemo, useState } from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+} from '@/components/ui/Dialog'
+import { isAutoAcceptable } from '@/lib/backend/auto-accept-policy'
+import type { FunctionEntry } from '@/lib/functions'
+import { cn } from '@/lib/utils'
+
+interface FunctionAllowlistTreeProps {
+ functions: FunctionEntry[]
+ /** Current allowlist (function ids). */
+ allowlist: ReadonlySet
+ onAdd: (functionId: string) => void
+ onRemove: (functionId: string) => void
+ /** Empty-state hint when the catalog has no entries (still loading, etc). */
+ emptyHint?: string
+}
+
+interface TreeNode {
+ segment: string
+ path: string
+ entry?: FunctionEntry
+ children: TreeNode[]
+}
+
+function buildTree(entries: ReadonlyArray): TreeNode[] {
+ const root: TreeNode = { segment: '', path: '', children: [] }
+ const sorted = [...entries].sort((a, b) => a.id.localeCompare(b.id))
+ for (const entry of sorted) {
+ const segments = entry.id.split('::').filter(Boolean)
+ if (segments.length === 0) continue
+ let cursor = root
+ let path = ''
+ for (let i = 0; i < segments.length; i++) {
+ const seg = segments[i]
+ path = path ? `${path}::${seg}` : seg
+ let child = cursor.children.find((c) => c.segment === seg)
+ if (!child) {
+ child = { segment: seg, path, children: [] }
+ cursor.children.push(child)
+ }
+ if (i === segments.length - 1) child.entry = entry
+ cursor = child
+ }
+ }
+ return root.children
+}
+
+interface NodeCounts {
+ total: number
+ allowed: number
+ destructive: number
+ leafIds: string[]
+}
+
+function collectCounts(
+ node: TreeNode,
+ allowlist: ReadonlySet,
+): NodeCounts {
+ let total = 0
+ let allowed = 0
+ let destructive = 0
+ const leafIds: string[] = []
+ if (node.entry) {
+ total += 1
+ leafIds.push(node.entry.id)
+ if (allowlist.has(node.entry.id)) allowed += 1
+ if (!isAutoAcceptable(node.entry.id)) destructive += 1
+ }
+ for (const child of node.children) {
+ const c = collectCounts(child, allowlist)
+ total += c.total
+ allowed += c.allowed
+ destructive += c.destructive
+ leafIds.push(...c.leafIds)
+ }
+ return { total, allowed, destructive, leafIds }
+}
+
+/**
+ * Recursive function-id tree grouped by `::`. Branch checkboxes are
+ * tri-state and toggle their subtree as a unit; destructive leaves and
+ * destructive bulk-adds gate on a confirmation dialog. Pure UI — the
+ * parent owns the allowlist state and add/remove callbacks.
+ */
+export function FunctionAllowlistTree({
+ functions,
+ allowlist,
+ onAdd,
+ onRemove,
+ emptyHint = 'no functions in the catalog yet.',
+}: FunctionAllowlistTreeProps) {
+ const tree = useMemo(() => buildTree(functions), [functions])
+ const [expanded, setExpanded] = useState>({})
+ /**
+ * FIFO of destructive function ids waiting on user confirmation. The
+ * modal renders queue[0]; confirm dequeues + adds, cancel clears the
+ * whole queue (so a single "cancel" stops the chain rather than the
+ * user having to dismiss N modals).
+ */
+ const [destructiveQueue, setDestructiveQueue] = useState([])
+ const pendingDestructive = destructiveQueue[0] ?? null
+ const remainingDestructive = Math.max(destructiveQueue.length - 1, 0)
+
+ function toggleBranch(path: string) {
+ setExpanded((prev) => ({ ...prev, [path]: !prev[path] }))
+ }
+
+ function setSubtreeAllowed(node: TreeNode, value: boolean) {
+ const { leafIds } = collectCounts(node, allowlist)
+ if (value) {
+ const nonDestructive = leafIds.filter((id) => isAutoAcceptable(id))
+ const destructive = leafIds.filter(
+ (id) => !isAutoAcceptable(id) && !allowlist.has(id),
+ )
+ for (const id of nonDestructive) {
+ if (!allowlist.has(id)) onAdd(id)
+ }
+ if (destructive.length > 0) {
+ setDestructiveQueue((prev) => [...prev, ...destructive])
+ }
+ } else {
+ for (const id of leafIds) {
+ if (allowlist.has(id)) onRemove(id)
+ }
+ }
+ }
+
+ function toggleLeaf(id: string) {
+ if (allowlist.has(id)) {
+ onRemove(id)
+ return
+ }
+ if (!isAutoAcceptable(id)) {
+ setDestructiveQueue((prev) => [...prev, id])
+ return
+ }
+ onAdd(id)
+ }
+
+ if (tree.length === 0) {
+ return (
+