mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 21:48:36 +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 = (
|
||||
<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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user