mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-28 23:48:39 +02:00
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:
@@ -253,6 +253,9 @@ export default function App() {
|
|||||||
content = (
|
content = (
|
||||||
<ChatPane
|
<ChatPane
|
||||||
onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
|
onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
|
||||||
|
onResolveApproval={(approvalId, decision) =>
|
||||||
|
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision })
|
||||||
|
}
|
||||||
onUpdateScratchpadConfig={(config) =>
|
onUpdateScratchpadConfig={(config) =>
|
||||||
api.updateScratchpadSessionConfig({
|
api.updateScratchpadSessionConfig({
|
||||||
sessionId: selectedSession.id,
|
sessionId: selectedSession.id,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, type ReactNode } from 'react';
|
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 {
|
import {
|
||||||
buildAgentActivityRows,
|
buildAgentActivityRows,
|
||||||
@@ -183,6 +183,7 @@ export function ActivityPanel({
|
|||||||
const selection = useMemo(() => resolveSessionToolingSelection(session), [session]);
|
const selection = useMemo(() => resolveSessionToolingSelection(session), [session]);
|
||||||
|
|
||||||
const isBusy = session.status === 'running';
|
const isBusy = session.status === 'running';
|
||||||
|
const hasPendingApproval = session.pendingApproval?.status === 'pending';
|
||||||
const toolsDisabled = isBusy || projectIsScratchpad;
|
const toolsDisabled = isBusy || projectIsScratchpad;
|
||||||
const accent = modeAccent[pattern.mode] ?? modeAccent.single;
|
const accent = modeAccent[pattern.mode] ?? modeAccent.single;
|
||||||
const hasTools = mcpServers.length > 0 || lspProfiles.length > 0;
|
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">
|
<span className="text-[12px] font-semibold uppercase tracking-[0.12em] text-zinc-400">
|
||||||
Activity
|
Activity
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
|
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 { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||||
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
||||||
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
||||||
|
import type { ApprovalDecision, PendingApprovalRecord } from '@shared/domain/approval';
|
||||||
import {
|
import {
|
||||||
findModel,
|
findModel,
|
||||||
getSupportedReasoningEfforts,
|
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 ──────────────────────────────────────────────── */
|
/* ── ChatPane ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
interface ChatPaneProps {
|
interface ChatPaneProps {
|
||||||
@@ -229,6 +314,7 @@ interface ChatPaneProps {
|
|||||||
session: SessionRecord;
|
session: SessionRecord;
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
onSend: (content: string) => Promise<void>;
|
onSend: (content: string) => Promise<void>;
|
||||||
|
onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>;
|
||||||
onUpdateScratchpadConfig?: (config: {
|
onUpdateScratchpadConfig?: (config: {
|
||||||
model: string;
|
model: string;
|
||||||
reasoningEffort?: ReasoningEffort;
|
reasoningEffort?: ReasoningEffort;
|
||||||
@@ -241,15 +327,19 @@ export function ChatPane({
|
|||||||
session,
|
session,
|
||||||
availableModels,
|
availableModels,
|
||||||
onSend,
|
onSend,
|
||||||
|
onResolveApproval,
|
||||||
onUpdateScratchpadConfig,
|
onUpdateScratchpadConfig,
|
||||||
}: ChatPaneProps) {
|
}: ChatPaneProps) {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [configError, setConfigError] = useState<string>();
|
const [configError, setConfigError] = useState<string>();
|
||||||
|
const [approvalError, setApprovalError] = useState<string>();
|
||||||
|
const [isResolvingApproval, setIsResolvingApproval] = useState(false);
|
||||||
const [isUpdatingScratchpadConfig, setIsUpdatingScratchpadConfig] = useState(false);
|
const [isUpdatingScratchpadConfig, setIsUpdatingScratchpadConfig] = useState(false);
|
||||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const isSessionBusy = session.status === 'running';
|
const isSessionBusy = session.status === 'running';
|
||||||
|
const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined;
|
||||||
const isScratchpad = isScratchpadProject(project);
|
const isScratchpad = isScratchpadProject(project);
|
||||||
const primaryAgent = pattern.agents[0];
|
const primaryAgent = pattern.agents[0];
|
||||||
const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined;
|
const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined;
|
||||||
@@ -266,6 +356,8 @@ export function ChatPane({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setConfigError(undefined);
|
setConfigError(undefined);
|
||||||
|
setApprovalError(undefined);
|
||||||
|
setIsResolvingApproval(false);
|
||||||
setIsUpdatingScratchpadConfig(false);
|
setIsUpdatingScratchpadConfig(false);
|
||||||
}, [session.id]);
|
}, [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>) {
|
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -335,14 +442,20 @@ export function ChatPane({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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' && (
|
{session.status === 'error' && (
|
||||||
<div className="flex items-center gap-1.5 text-[12px] text-red-400">
|
<div className="flex items-center gap-1.5 text-[12px] text-red-400">
|
||||||
<AlertCircle className="size-3.5" />
|
<AlertCircle className="size-3.5" />
|
||||||
Error
|
Error
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{session.status === 'idle' && session.messages.length > 0 && (
|
{session.status === 'idle' && !pendingApproval && session.messages.length > 0 && (
|
||||||
<span className="text-[12px] text-zinc-600">
|
<span className="text-[12px] text-zinc-600">
|
||||||
{session.messages.length} message{session.messages.length === 1 ? '' : 's'}
|
{session.messages.length} message{session.messages.length === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
@@ -456,7 +569,25 @@ export function ChatPane({
|
|||||||
</div>
|
</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">
|
<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 */}
|
{/* Scratchpad config pills — inline above composer */}
|
||||||
{isScratchpad && primaryAgent && (
|
{isScratchpad && primaryAgent && (
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
@@ -496,11 +627,13 @@ export function ChatPane({
|
|||||||
onChange={(event) => setInput(event.target.value)}
|
onChange={(event) => setInput(event.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
isSessionBusy
|
pendingApproval
|
||||||
? 'Waiting for response...'
|
? 'Awaiting approval...'
|
||||||
: isUpdatingScratchpadConfig
|
: isSessionBusy
|
||||||
? 'Saving scratchpad settings...'
|
? 'Waiting for response...'
|
||||||
: 'Message...'
|
: isUpdatingScratchpadConfig
|
||||||
|
? 'Saving scratchpad settings...'
|
||||||
|
: 'Message...'
|
||||||
}
|
}
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Plus,
|
Plus,
|
||||||
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import type { ApprovalCheckpointKind, ApprovalPolicy } from '@shared/domain/approval';
|
||||||
import {
|
import {
|
||||||
findModel,
|
findModel,
|
||||||
getSupportedReasoningEfforts,
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Header — consistent ← navigation */}
|
{/* Header — consistent ← navigation */}
|
||||||
@@ -463,8 +496,160 @@ export function PatternEditor({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' : ''}`}>
|
<span className={`text-[11px] font-medium ${terminal ? 'text-zinc-600' : 'text-zinc-300'} ${isClickable ? 'group-hover:text-indigo-300' : ''}`}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</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>
|
<span className="ml-auto shrink-0 text-[9px] tabular-nums text-zinc-700">{timestamp}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,6 +134,13 @@ function TimelineEventRow({
|
|||||||
</p>
|
</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 */}
|
{/* Error detail */}
|
||||||
{event.error && (
|
{event.error && (
|
||||||
<p className="mt-0.5 text-[10px] leading-snug text-red-500/80">
|
<p className="mt-0.5 text-[10px] leading-snug text-red-500/80">
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ function SessionItem({
|
|||||||
}) {
|
}) {
|
||||||
const isRunning = session.status === 'running';
|
const isRunning = session.status === 'running';
|
||||||
const isError = session.status === 'error';
|
const isError = session.status === 'error';
|
||||||
|
const hasPendingApproval = session.pendingApproval?.status === 'pending';
|
||||||
const mode = pattern?.mode ?? 'single';
|
const mode = pattern?.mode ?? 'single';
|
||||||
const visual = modeVisuals[mode];
|
const visual = modeVisuals[mode];
|
||||||
const ModeIcon = visual.icon;
|
const ModeIcon = visual.icon;
|
||||||
@@ -214,10 +215,13 @@ function SessionItem({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && !isRenaming) onSelect(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter' && !isRenaming) onSelect(); }}
|
||||||
>
|
>
|
||||||
{/* Running left accent bar */}
|
{/* Running/approval left accent bar */}
|
||||||
{isRunning && (
|
{isRunning && !hasPendingApproval && (
|
||||||
<span className="absolute inset-y-1.5 left-0 w-[3px] rounded-full bg-blue-400 sidebar-pulse" />
|
<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 */}
|
{/* Mode icon */}
|
||||||
<span
|
<span
|
||||||
@@ -260,12 +264,18 @@ function SessionItem({
|
|||||||
{agentCount}
|
{agentCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isRunning && (
|
{isRunning && !hasPendingApproval && (
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-blue-400">
|
<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" />
|
<span className="size-1.5 rounded-full bg-blue-400 sidebar-pulse" />
|
||||||
Running
|
Running
|
||||||
</span>
|
</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 && (
|
{isError && (
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-red-400">
|
<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" />
|
<span className="size-1.5 rounded-full bg-red-400" />
|
||||||
|
|||||||
Reference in New Issue
Block a user