feat: add turn cancellation UI and main process wiring

Adds cancel-turn IPC channel, SidecarClient.cancelTurn(), TurnCancelledError,
EryxAppService.cancelSessionTurn(), finalizeCancelledTurn(), stop button in
ChatPane, and cancelled run status throughout the run timeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-25 22:17:15 +01:00
co-authored by Copilot
parent d84b3021f2
commit 82fdadb312
13 changed files with 265 additions and 49 deletions
+1
View File
@@ -246,6 +246,7 @@ export default function App() {
content = (
<ChatPane
onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }}
onResolveApproval={(approvalId, decision) =>
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision })
}
+18 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { AlertCircle, ArrowUp, Bot, Circle, GitBranch, Loader2, ShieldAlert, User } from 'lucide-react';
import { AlertCircle, ArrowUp, Bot, Circle, GitBranch, Loader2, ShieldAlert, Square, User } from 'lucide-react';
import { MarkdownContent } from '@renderer/components/MarkdownContent';
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
@@ -34,6 +34,7 @@ interface ChatPaneProps {
toolingSettings: WorkspaceToolingSettings;
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
onSend: (content: string) => Promise<void>;
onCancelTurn?: () => void;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>;
onUpdateScratchpadConfig?: (config: {
model: string;
@@ -51,6 +52,7 @@ export function ChatPane({
toolingSettings,
runtimeTools,
onSend,
onCancelTurn,
onResolveApproval,
onUpdateScratchpadConfig,
onUpdateSessionTooling,
@@ -432,16 +434,25 @@ export function ChatPane({
>
<button
className={`absolute bottom-2 right-2 flex size-8 items-center justify-center rounded-lg transition ${
canSubmitInput
? 'bg-indigo-600 text-white hover:bg-indigo-500'
: 'bg-zinc-800 text-zinc-600'
isSessionBusy
? 'bg-red-600/80 text-white hover:bg-red-500'
: canSubmitInput
? 'bg-indigo-600 text-white hover:bg-indigo-500'
: 'bg-zinc-800 text-zinc-600'
}`}
disabled={!canSubmitInput}
onClick={() => composerRef.current?.submit()}
disabled={!canSubmitInput && !isSessionBusy}
onClick={() => {
if (isSessionBusy) {
onCancelTurn?.();
} else {
composerRef.current?.submit();
}
}}
type="button"
aria-label={isSessionBusy ? 'Stop generating' : 'Send message'}
>
{isSessionBusy ? (
<Loader2 className="size-4 animate-spin" />
<Square className="size-3.5" fill="currentColor" />
) : (
<ArrowUp className="size-4" />
)}
+3
View File
@@ -43,6 +43,7 @@ const modeAccent: Record<OrchestrationMode, { dot: string; ring: string; text: s
const runStatusStyles: Record<SessionRunRecord['status'], { icon: ReactNode; className: string }> = {
running: { icon: <CircleDot className="size-3" />, className: 'text-blue-400' },
completed: { icon: <CheckCircle2 className="size-3" />, className: 'text-emerald-400' },
cancelled: { icon: <XCircle className="size-3" />, className: 'text-zinc-400' },
error: { icon: <XCircle className="size-3" />, className: 'text-red-400' },
};
@@ -65,6 +66,8 @@ function EventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; sta
return <MessageSquare className={`${base} ${status === 'running' ? 'text-blue-400 animate-pulse' : status === 'error' ? 'text-red-400' : 'text-emerald-400'}`} />;
case 'run-completed':
return <CheckCircle2 className={`${base} text-emerald-400`} />;
case 'run-cancelled':
return <XCircle className={`${base} text-zinc-400`} />;
case 'run-failed':
return <AlertTriangle className={`${base} text-red-400`} />;
}
+6 -1
View File
@@ -59,6 +59,8 @@ export function formatRunStatusLabel(status: SessionRunStatus): string {
return 'Running';
case 'completed':
return 'Completed';
case 'cancelled':
return 'Cancelled';
case 'error':
return 'Failed';
}
@@ -91,6 +93,8 @@ export function formatEventLabel(event: RunTimelineEventRecord): string {
return event.agentName ?? 'Response';
case 'run-completed':
return 'Completed';
case 'run-cancelled':
return 'Cancelled';
case 'run-failed':
return 'Failed';
}
@@ -144,11 +148,12 @@ const eventKindOrder: Record<RunTimelineEventKind, number> = {
'approval': 4,
'message': 5,
'run-completed': 6,
'run-cancelled': 6,
'run-failed': 6,
};
export function isTerminalEvent(kind: RunTimelineEventKind): boolean {
return kind === 'run-completed' || kind === 'run-failed' || kind === 'run-started';
return kind === 'run-completed' || kind === 'run-cancelled' || kind === 'run-failed' || kind === 'run-started';
}
export function eventSortKey(event: RunTimelineEventRecord): number {