From f54cbdb6b133d366d78fef482bceacbf873c9b7e Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 24 Mar 2026 19:52:23 +0100 Subject: [PATCH] feat: add approval checkpoint UX across renderer - ChatPane: approval banner with approve/reject, final-response preview, error handling - PatternEditor: approval checkpoints section with toggles and agent scope selector - Sidebar: amber awaiting-approval badge replaces running indicator when pending - ActivityPanel: shield icon approval badge in header - RunTimeline: approval kind badge, detail text, status-aware colors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/renderer/App.tsx | 3 + src/renderer/components/ActivityPanel.tsx | 12 +- src/renderer/components/ChatPane.tsx | 149 ++++++++++++++++- src/renderer/components/PatternEditor.tsx | 185 ++++++++++++++++++++++ src/renderer/components/RunTimeline.tsx | 19 +++ src/renderer/components/Sidebar.tsx | 16 +- 6 files changed, 371 insertions(+), 13 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 929a6b8..ce7d4dc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -253,6 +253,9 @@ export default function App() { content = ( api.sendSessionMessage({ sessionId: selectedSession.id, content: c })} + onResolveApproval={(approvalId, decision) => + api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision }) + } onUpdateScratchpadConfig={(config) => api.updateScratchpadSessionConfig({ sessionId: selectedSession.id, diff --git a/src/renderer/components/ActivityPanel.tsx b/src/renderer/components/ActivityPanel.tsx index cc43d54..db71435 100644 --- a/src/renderer/components/ActivityPanel.tsx +++ b/src/renderer/components/ActivityPanel.tsx @@ -1,5 +1,5 @@ import { useMemo, type ReactNode } from 'react'; -import { Activity, Clock, Server, Code, Sparkles, Users } from 'lucide-react'; +import { Activity, Clock, Server, Code, ShieldAlert, Sparkles, Users } from 'lucide-react'; import { buildAgentActivityRows, @@ -183,6 +183,7 @@ export function ActivityPanel({ const selection = useMemo(() => resolveSessionToolingSelection(session), [session]); const isBusy = session.status === 'running'; + const hasPendingApproval = session.pendingApproval?.status === 'pending'; const toolsDisabled = isBusy || projectIsScratchpad; const accent = modeAccent[pattern.mode] ?? modeAccent.single; const hasTools = mcpServers.length > 0 || lspProfiles.length > 0; @@ -196,7 +197,14 @@ export function ActivityPanel({ Activity - {isBusy && } + {hasPendingApproval ? ( + + + Approval + + ) : isBusy ? ( + + ) : null} diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 7286914..4fdae22 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -1,9 +1,10 @@ import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; -import { AlertCircle, ArrowUp, Bot, ChevronDown, Circle, GitBranch, Loader2, Sparkles, User } from 'lucide-react'; +import { AlertCircle, ArrowUp, Bot, Check, ChevronDown, Circle, GitBranch, Loader2, ShieldAlert, ShieldCheck, Sparkles, User, X } from 'lucide-react'; import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import { ProviderIcon } from '@renderer/components/ProviderIcons'; +import type { ApprovalDecision, PendingApprovalRecord } from '@shared/domain/approval'; import { findModel, getSupportedReasoningEfforts, @@ -221,6 +222,90 @@ function InlineThinkingPill({ ); } +/* ── Approval banner ────────────────────────────────────────── */ + +function ApprovalBanner({ + approval, + onResolve, + isResolving, +}: { + approval: PendingApprovalRecord; + onResolve: (decision: ApprovalDecision) => void; + isResolving: boolean; +}) { + const kindLabel = approval.kind === 'final-response' ? 'Final response review' : 'Tool call approval'; + const hasMessages = approval.messages && approval.messages.length > 0; + + return ( +
+ {/* Header */} +
+ +
+
+ {approval.title} + + {kindLabel} + +
+ + {/* Agent / permission context */} +
+ {approval.agentName && Agent: {approval.agentName}} + {approval.permissionKind && Permission: {approval.permissionKind}} +
+ + {approval.detail && ( +

{approval.detail}

+ )} +
+
+ + {/* Final-response message preview */} + {hasMessages && ( +
+

+ Pending messages — not yet published +

+ {approval.messages!.map((message) => ( +
+
+ + {message.authorName} +
+
+ +
+
+ ))} +
+ )} + + {/* Actions */} +
+ + +
+
+ ); +} + /* ── ChatPane ──────────────────────────────────────────────── */ interface ChatPaneProps { @@ -229,6 +314,7 @@ interface ChatPaneProps { session: SessionRecord; availableModels: ReadonlyArray; onSend: (content: string) => Promise; + onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise; onUpdateScratchpadConfig?: (config: { model: string; reasoningEffort?: ReasoningEffort; @@ -241,15 +327,19 @@ export function ChatPane({ session, availableModels, onSend, + onResolveApproval, onUpdateScratchpadConfig, }: ChatPaneProps) { const [input, setInput] = useState(''); const [configError, setConfigError] = useState(); + const [approvalError, setApprovalError] = useState(); + const [isResolvingApproval, setIsResolvingApproval] = useState(false); const [isUpdatingScratchpadConfig, setIsUpdatingScratchpadConfig] = useState(false); const transcriptRef = useRef(null); const textareaRef = useRef(null); const isSessionBusy = session.status === 'running'; + const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined; const isScratchpad = isScratchpadProject(project); const primaryAgent = pattern.agents[0]; const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined; @@ -266,6 +356,8 @@ export function ChatPane({ useEffect(() => { setConfigError(undefined); + setApprovalError(undefined); + setIsResolvingApproval(false); setIsUpdatingScratchpadConfig(false); }, [session.id]); @@ -303,6 +395,21 @@ export function ChatPane({ } } + async function handleResolveApproval(decision: ApprovalDecision) { + if (!pendingApproval || !onResolveApproval || isResolvingApproval) return; + + setApprovalError(undefined); + setIsResolvingApproval(true); + + try { + await onResolveApproval(pendingApproval.id, decision); + } catch (error) { + setApprovalError(error instanceof Error ? error.message : String(error)); + } finally { + setIsResolvingApproval(false); + } + } + function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); @@ -335,14 +442,20 @@ export function ChatPane({

- {isSessionBusy && } + {pendingApproval && ( +
+ + Awaiting approval +
+ )} + {isSessionBusy && !pendingApproval && } {session.status === 'error' && (
Error
)} - {session.status === 'idle' && session.messages.length > 0 && ( + {session.status === 'idle' && !pendingApproval && session.messages.length > 0 && ( {session.messages.length} message{session.messages.length === 1 ? '' : 's'} @@ -456,7 +569,25 @@ export function ChatPane({
)} + {approvalError && ( +
+ + {approvalError} +
+ )} +
+ {/* Pending approval banner */} + {pendingApproval && ( +
+ void handleResolveApproval(decision)} + /> +
+ )} + {/* Scratchpad config pills — inline above composer */} {isScratchpad && primaryAgent && (
@@ -496,11 +627,13 @@ export function ChatPane({ onChange={(event) => setInput(event.target.value)} onKeyDown={handleKeyDown} placeholder={ - isSessionBusy - ? 'Waiting for response...' - : isUpdatingScratchpadConfig - ? 'Saving scratchpad settings...' - : 'Message...' + pendingApproval + ? 'Awaiting approval...' + : isSessionBusy + ? 'Waiting for response...' + : isUpdatingScratchpadConfig + ? 'Saving scratchpad settings...' + : 'Message...' } ref={textareaRef} rows={1} diff --git a/src/renderer/components/PatternEditor.tsx b/src/renderer/components/PatternEditor.tsx index b4b77a8..374231f 100644 --- a/src/renderer/components/PatternEditor.tsx +++ b/src/renderer/components/PatternEditor.tsx @@ -10,11 +10,13 @@ import { Lock, MessageSquare, Plus, + ShieldCheck, Trash2, Users, type LucideIcon, } from 'lucide-react'; +import type { ApprovalCheckpointKind, ApprovalPolicy } from '@shared/domain/approval'; import { findModel, getSupportedReasoningEfforts, @@ -222,6 +224,37 @@ export function PatternEditor({ }); } + function updateApprovalPolicy(updater: (current: ApprovalPolicy | undefined) => ApprovalPolicy | undefined) { + onChange({ ...pattern, approvalPolicy: updater(pattern.approvalPolicy) }); + } + + function isCheckpointEnabled(kind: ApprovalCheckpointKind): boolean { + return pattern.approvalPolicy?.rules.some((r) => r.kind === kind) ?? false; + } + + function checkpointAgentIds(kind: ApprovalCheckpointKind): string[] | undefined { + return pattern.approvalPolicy?.rules.find((r) => r.kind === kind)?.agentIds; + } + + function toggleCheckpoint(kind: ApprovalCheckpointKind, enabled: boolean) { + updateApprovalPolicy((current) => { + const otherRules = (current?.rules ?? []).filter((r) => r.kind !== kind); + if (!enabled) { + return otherRules.length > 0 ? { rules: otherRules } : undefined; + } + return { rules: [...otherRules, { kind }] }; + }); + } + + function setCheckpointAgentScope(kind: ApprovalCheckpointKind, agentIds: string[] | undefined) { + updateApprovalPolicy((current) => { + const rules = (current?.rules ?? []).map((r) => + r.kind === kind ? { ...r, agentIds } : r, + ); + return { rules }; + }); + } + return (
{/* Header — consistent ← navigation */} @@ -463,8 +496,160 @@ export function PatternEditor({ ))}
+ + {/* Approval checkpoints */} +
+

+ Approval Checkpoints +

+ +

+ Pause the run for human review before risky actions or publishing responses. +

+ +
+ toggleCheckpoint('tool-call', enabled)} + scopedAgentIds={checkpointAgentIds('tool-call')} + onScopeChange={(agentIds) => setCheckpointAgentScope('tool-call', agentIds)} + /> + toggleCheckpoint('final-response', enabled)} + scopedAgentIds={checkpointAgentIds('final-response')} + onScopeChange={(agentIds) => setCheckpointAgentScope('final-response', agentIds)} + /> +
+
); } + +/* ── Toggle switch ─────────────────────────────────────────── */ + +function ToggleSwitch({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) { + return ( + + ); +} + +/* ── Approval checkpoint row ───────────────────────────────── */ + +function ApprovalCheckpointRow({ + agents, + enabled, + kind: _kind, + label, + description, + onToggle, + scopedAgentIds, + onScopeChange, +}: { + agents: PatternAgentDefinition[]; + enabled: boolean; + kind: ApprovalCheckpointKind; + label: string; + description: string; + onToggle: (enabled: boolean) => void; + scopedAgentIds: string[] | undefined; + onScopeChange: (agentIds: string[] | undefined) => void; +}) { + const isAllAgents = !scopedAgentIds || scopedAgentIds.length === 0; + + function toggleAgentScope(agentId: string) { + const current = scopedAgentIds ?? []; + const next = current.includes(agentId) + ? current.filter((id) => id !== agentId) + : [...current, agentId]; + onScopeChange(next.length > 0 ? next : undefined); + } + + return ( +
+
+ +
+ {label} +

{description}

+
+ onToggle(!enabled)} /> +
+ + {/* Agent scope selector */} + {enabled && agents.length > 1 && ( +
+
+ Scope + + +
+ + {!isAllAgents && ( +
+ {agents.map((agent) => { + const isSelected = scopedAgentIds?.includes(agent.id) ?? false; + return ( + + ); + })} +
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/renderer/components/RunTimeline.tsx b/src/renderer/components/RunTimeline.tsx index 361bb4d..395534a 100644 --- a/src/renderer/components/RunTimeline.tsx +++ b/src/renderer/components/RunTimeline.tsx @@ -112,6 +112,18 @@ function TimelineEventRow({ {label} + {/* Approval kind badge */} + {event.kind === 'approval' && event.approvalKind && ( + + {event.approvalKind === 'final-response' ? 'response' : 'tool'} + + )} {timestamp} @@ -122,6 +134,13 @@ function TimelineEventRow({

)} + {/* Approval detail */} + {event.kind === 'approval' && event.approvalDetail && ( +

+ {truncateContent(event.approvalDetail, 120)} +

+ )} + {/* Error detail */} {event.error && (

diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 51b52f4..49bc29e 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -167,6 +167,7 @@ function SessionItem({ }) { const isRunning = session.status === 'running'; const isError = session.status === 'error'; + const hasPendingApproval = session.pendingApproval?.status === 'pending'; const mode = pattern?.mode ?? 'single'; const visual = modeVisuals[mode]; const ModeIcon = visual.icon; @@ -214,10 +215,13 @@ function SessionItem({ tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' && !isRenaming) onSelect(); }} > - {/* Running left accent bar */} - {isRunning && ( + {/* Running/approval left accent bar */} + {isRunning && !hasPendingApproval && ( )} + {hasPendingApproval && ( + + )} {/* Mode icon */} )} - {isRunning && ( + {isRunning && !hasPendingApproval && ( Running )} + {hasPendingApproval && ( + + + Awaiting approval + + )} {isError && (