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' && (
)}
- {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 && (
+
+ )}
+
+ {/* 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 && (