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>
This commit is contained in:
David Kaya
2026-03-24 19:52:23 +01:00
co-authored by Copilot
parent 2aa8d73b2d
commit f54cbdb6b1
6 changed files with 371 additions and 13 deletions
+3
View File
@@ -253,6 +253,9 @@ export default function App() {
content = (
<ChatPane
onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
onResolveApproval={(approvalId, decision) =>
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision })
}
onUpdateScratchpadConfig={(config) =>
api.updateScratchpadSessionConfig({
sessionId: selectedSession.id,
+10 -2
View File
@@ -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({
<span className="text-[12px] font-semibold uppercase tracking-[0.12em] text-zinc-400">
Activity
</span>
{isBusy && <span className="size-1.5 animate-pulse rounded-full bg-blue-400" />}
{hasPendingApproval ? (
<span className="flex items-center gap-1">
<ShieldAlert className="size-3 text-amber-400" />
<span className="text-[9px] font-semibold uppercase tracking-wider text-amber-400">Approval</span>
</span>
) : isBusy ? (
<span className="size-1.5 animate-pulse rounded-full bg-blue-400" />
) : null}
</div>
</div>
+141 -8
View File
@@ -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 (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3">
{/* Header */}
<div className="flex items-start gap-2.5">
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-amber-200">{approval.title}</span>
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400">
{kindLabel}
</span>
</div>
{/* Agent / permission context */}
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
{approval.agentName && <span>Agent: <span className="text-zinc-300">{approval.agentName}</span></span>}
{approval.permissionKind && <span>Permission: <span className="text-zinc-300">{approval.permissionKind}</span></span>}
</div>
{approval.detail && (
<p className="mt-1.5 text-[12px] leading-relaxed text-zinc-400">{approval.detail}</p>
)}
</div>
</div>
{/* Final-response message preview */}
{hasMessages && (
<div className="mt-3 space-y-2 rounded-lg border border-zinc-800 bg-zinc-900/60 p-3">
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
Pending messages not yet published
</p>
{approval.messages!.map((message) => (
<div className="mt-2" key={message.id}>
<div className="mb-1 flex items-center gap-2 text-[11px] font-medium text-zinc-500">
<Bot className="size-3" />
<span>{message.authorName}</span>
</div>
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/40 px-3 py-2 text-[13px] leading-relaxed text-zinc-300">
<MarkdownContent content={message.content} />
</div>
</div>
))}
</div>
)}
{/* Actions */}
<div className="mt-3 flex items-center gap-2">
<button
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3.5 py-1.5 text-[12px] font-medium text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isResolving}
onClick={() => onResolve('approved')}
type="button"
>
{isResolving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
Approve
</button>
<button
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3.5 py-1.5 text-[12px] font-medium text-zinc-300 transition hover:bg-zinc-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={isResolving}
onClick={() => onResolve('rejected')}
type="button"
>
<X className="size-3" />
Reject
</button>
</div>
</div>
);
}
/* ── ChatPane ──────────────────────────────────────────────── */
interface ChatPaneProps {
@@ -229,6 +314,7 @@ interface ChatPaneProps {
session: SessionRecord;
availableModels: ReadonlyArray<ModelDefinition>;
onSend: (content: string) => Promise<void>;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>;
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<string>();
const [approvalError, setApprovalError] = useState<string>();
const [isResolvingApproval, setIsResolvingApproval] = useState(false);
const [isUpdatingScratchpadConfig, setIsUpdatingScratchpadConfig] = useState(false);
const transcriptRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
@@ -335,14 +442,20 @@ export function ChatPane({
</p>
</div>
<div className="flex items-center gap-2">
{isSessionBusy && <span className="size-2 animate-pulse rounded-full bg-blue-400" />}
{pendingApproval && (
<div className="flex items-center gap-1.5 text-[12px] font-medium text-amber-400">
<ShieldAlert className="size-3.5" />
Awaiting approval
</div>
)}
{isSessionBusy && !pendingApproval && <span className="size-2 animate-pulse rounded-full bg-blue-400" />}
{session.status === 'error' && (
<div className="flex items-center gap-1.5 text-[12px] text-red-400">
<AlertCircle className="size-3.5" />
Error
</div>
)}
{session.status === 'idle' && session.messages.length > 0 && (
{session.status === 'idle' && !pendingApproval && session.messages.length > 0 && (
<span className="text-[12px] text-zinc-600">
{session.messages.length} message{session.messages.length === 1 ? '' : 's'}
</span>
@@ -456,7 +569,25 @@ export function ChatPane({
</div>
)}
{approvalError && (
<div className="mb-3 flex items-start gap-2 rounded-lg bg-red-500/10 px-3 py-2 text-[13px] text-red-300">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-red-400" />
<span>{approvalError}</span>
</div>
)}
<div className="mx-auto max-w-3xl">
{/* Pending approval banner */}
{pendingApproval && (
<div className="mb-3">
<ApprovalBanner
approval={pendingApproval}
isResolving={isResolvingApproval}
onResolve={(decision) => void handleResolveApproval(decision)}
/>
</div>
)}
{/* Scratchpad config pills — inline above composer */}
{isScratchpad && primaryAgent && (
<div className="mb-2 flex items-center gap-2">
@@ -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}
+185
View File
@@ -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 (
<div className="flex h-full flex-col">
{/* Header — consistent ← navigation */}
@@ -463,8 +496,160 @@ export function PatternEditor({
))}
</div>
</section>
{/* Approval checkpoints */}
<section className="space-y-4">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
Approval Checkpoints
</h4>
<p className="text-[11px] leading-relaxed text-zinc-600">
Pause the run for human review before risky actions or publishing responses.
</p>
<div className="space-y-3">
<ApprovalCheckpointRow
agents={pattern.agents}
enabled={isCheckpointEnabled('tool-call')}
kind="tool-call"
label="Tool call approval"
description="Require approval before the agent executes tool calls"
onToggle={(enabled) => toggleCheckpoint('tool-call', enabled)}
scopedAgentIds={checkpointAgentIds('tool-call')}
onScopeChange={(agentIds) => setCheckpointAgentScope('tool-call', agentIds)}
/>
<ApprovalCheckpointRow
agents={pattern.agents}
enabled={isCheckpointEnabled('final-response')}
kind="final-response"
label="Final response review"
description="Review and approve assistant messages before publication"
onToggle={(enabled) => toggleCheckpoint('final-response', enabled)}
scopedAgentIds={checkpointAgentIds('final-response')}
onScopeChange={(agentIds) => setCheckpointAgentScope('final-response', agentIds)}
/>
</div>
</section>
</div>
</div>
</div>
);
}
/* ── Toggle switch ─────────────────────────────────────────── */
function ToggleSwitch({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) {
return (
<button
className={`relative inline-flex h-[18px] w-[32px] shrink-0 items-center rounded-full transition-colors ${
enabled ? 'bg-indigo-500' : 'bg-zinc-700'
}`}
onClick={onToggle}
type="button"
>
<span
className={`inline-block size-[14px] rounded-full bg-white shadow-sm transition-transform ${
enabled ? 'translate-x-[16px]' : 'translate-x-[2px]'
}`}
/>
</button>
);
}
/* ── 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 (
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
<div className="flex items-center gap-3">
<ShieldCheck className={`size-4 shrink-0 ${enabled ? 'text-indigo-400' : 'text-zinc-600'}`} />
<div className="min-w-0 flex-1">
<span className="text-[12px] font-medium text-zinc-200">{label}</span>
<p className="text-[11px] text-zinc-500">{description}</p>
</div>
<ToggleSwitch enabled={enabled} onToggle={() => onToggle(!enabled)} />
</div>
{/* Agent scope selector */}
{enabled && agents.length > 1 && (
<div className="mt-3 border-t border-zinc-800/50 pt-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-[11px] font-medium text-zinc-400">Scope</span>
<button
className={`rounded-full px-2 py-0.5 text-[10px] font-medium transition ${
isAllAgents
? 'bg-indigo-500/15 text-indigo-300'
: 'bg-zinc-800 text-zinc-500 hover:text-zinc-400'
}`}
onClick={() => onScopeChange(undefined)}
type="button"
>
All agents
</button>
<button
className={`rounded-full px-2 py-0.5 text-[10px] font-medium transition ${
!isAllAgents
? 'bg-indigo-500/15 text-indigo-300'
: 'bg-zinc-800 text-zinc-500 hover:text-zinc-400'
}`}
onClick={() => onScopeChange([])}
type="button"
>
Selected agents
</button>
</div>
{!isAllAgents && (
<div className="flex flex-wrap gap-1.5">
{agents.map((agent) => {
const isSelected = scopedAgentIds?.includes(agent.id) ?? false;
return (
<button
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition ${
isSelected
? 'bg-indigo-500/20 text-indigo-300 ring-1 ring-indigo-500/30'
: 'bg-zinc-800 text-zinc-500 hover:bg-zinc-700 hover:text-zinc-400'
}`}
key={agent.id}
onClick={() => toggleAgentScope(agent.id)}
type="button"
>
{agent.name || 'Unnamed'}
</button>
);
})}
</div>
)}
</div>
)}
</div>
);
}
+19
View File
@@ -112,6 +112,18 @@ function TimelineEventRow({
<span className={`text-[11px] font-medium ${terminal ? 'text-zinc-600' : 'text-zinc-300'} ${isClickable ? 'group-hover:text-indigo-300' : ''}`}>
{label}
</span>
{/* Approval kind badge */}
{event.kind === 'approval' && event.approvalKind && (
<span className={`rounded-full px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wider ${
event.status === 'running'
? 'bg-amber-500/15 text-amber-400'
: event.status === 'completed'
? 'bg-emerald-500/15 text-emerald-400'
: 'bg-red-500/15 text-red-400'
}`}>
{event.approvalKind === 'final-response' ? 'response' : 'tool'}
</span>
)}
<span className="ml-auto shrink-0 text-[9px] tabular-nums text-zinc-700">{timestamp}</span>
</div>
@@ -122,6 +134,13 @@ function TimelineEventRow({
</p>
)}
{/* Approval detail */}
{event.kind === 'approval' && event.approvalDetail && (
<p className="mt-0.5 text-[10px] leading-snug text-zinc-500">
{truncateContent(event.approvalDetail, 120)}
</p>
)}
{/* Error detail */}
{event.error && (
<p className="mt-0.5 text-[10px] leading-snug text-red-500/80">
+13 -3
View File
@@ -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 && (
<span className="absolute inset-y-1.5 left-0 w-[3px] rounded-full bg-blue-400 sidebar-pulse" />
)}
{hasPendingApproval && (
<span className="absolute inset-y-1.5 left-0 w-[3px] rounded-full bg-amber-400" />
)}
{/* Mode icon */}
<span
@@ -260,12 +264,18 @@ function SessionItem({
{agentCount}
</span>
)}
{isRunning && (
{isRunning && !hasPendingApproval && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-blue-400">
<span className="size-1.5 rounded-full bg-blue-400 sidebar-pulse" />
Running
</span>
)}
{hasPendingApproval && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-400">
<span className="size-1.5 rounded-full bg-amber-400 animate-pulse" />
Awaiting approval
</span>
)}
{isError && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-red-400">
<span className="size-1.5 rounded-full bg-red-400" />