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
+53
View File
@@ -65,6 +65,7 @@ import {
import { prepareChatMessageContent } from '@shared/utils/chatMessage';
import {
appendRunActivityEvent,
cancelSessionRunRecord,
completeSessionRunRecord,
createSessionRunRecord,
failSessionRunRecord,
@@ -99,6 +100,7 @@ import {
SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE,
SidecarClient,
} from '@main/sidecar/sidecarProcess';
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
import { GitService } from '@main/git/gitService';
import {
buildRunTurnToolingConfig as buildSessionToolingConfig,
@@ -621,6 +623,12 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
await this.persistAndBroadcast(workspace);
} catch (error) {
if (error instanceof TurnCancelledError) {
this.finalizeCancelledTurn(workspace, session, requestId);
await this.persistAndBroadcast(workspace);
return;
}
const failedAt = nowIso();
session.status = 'error';
session.lastError = error instanceof Error ? error.message : String(error);
@@ -643,6 +651,21 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
}
}
async cancelSessionTurn(sessionId: string): Promise<void> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
if (session.status !== 'running') {
return;
}
const runningRun = session.runs.find((run) => run.status === 'running');
if (!runningRun) {
return;
}
await this.sidecar.cancelTurn(runningRun.requestId);
}
async resolveSessionApproval(
sessionId: string,
approvalId: string,
@@ -1101,6 +1124,36 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
}
}
private finalizeCancelledTurn(
workspace: WorkspaceState,
session: SessionRecord,
requestId: string,
): void {
for (const message of session.messages) {
if (message.pending) {
message.pending = false;
}
}
this.rejectPendingApprovals(session, nowIso(), 'The turn was cancelled.');
const cancelledAt = nowIso();
session.status = 'idle';
session.lastError = undefined;
session.updatedAt = cancelledAt;
const cancelledRun = this.updateSessionRun(session, requestId, (run) =>
cancelSessionRunRecord(run, cancelledAt));
this.emitSessionEvent({
sessionId: session.id,
kind: 'status',
occurredAt: cancelledAt,
status: 'idle',
});
if (cancelledRun) {
this.emitRunUpdated(session.id, cancelledAt, cancelledRun);
}
}
private async handleApprovalRequested(
workspace: WorkspaceState,
sessionId: string,
+4
View File
@@ -2,6 +2,7 @@ import { BrowserWindow, ipcMain } from 'electron';
import { ipcChannels } from '@shared/contracts/channels';
import type {
CancelSessionTurnInput,
CreateSessionInput,
ResolveProjectDiscoveredToolingInput,
ResolveWorkspaceDiscoveredToolingInput,
@@ -100,6 +101,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
service.sendSessionMessage(input.sessionId, input.content),
);
ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) =>
service.cancelSessionTurn(input.sessionId),
);
ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) =>
service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision),
);
+138 -39
View File
@@ -4,6 +4,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import type {
AgentActivityEvent,
ApprovalRequestedEvent,
CancelTurnCommand,
SidecarCommand,
SidecarCapabilities,
SidecarEvent,
@@ -19,29 +20,54 @@ import {
shouldHandleRunTurnEvent,
type RunTurnPendingCommand,
} from '@main/sidecar/runTurnPending';
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime';
type PendingCommand =
| {
| ({
processId: number;
kind: 'capabilities';
resolve: (capabilities: SidecarCapabilities) => void;
reject: (error: Error) => void;
}
| {
})
| ({
processId: number;
kind: 'validate-pattern';
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
reject: (error: Error) => void;
}
| {
})
| ({
processId: number;
kind: 'resolve-approval';
resolve: () => void;
reject: (error: Error) => void;
}
| RunTurnPendingCommand;
})
| ({
processId: number;
kind: 'cancel-turn';
resolve: () => void;
reject: (error: Error) => void;
})
| ({
processId: number;
} & RunTurnPendingCommand);
type ManagedSidecarProcess = {
id: number;
child: ChildProcessWithoutNullStreams;
stdoutBuffer: string;
exitExpected: boolean;
terminated: boolean;
closed: Promise<void>;
resolveClosed: () => void;
};
export const SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE =
'The .NET sidecar was stopped before the command completed.';
export class SidecarClient {
private process?: ChildProcessWithoutNullStreams;
private stdoutBuffer = '';
private processState?: ManagedSidecarProcess;
private nextProcessId = 0;
private readonly pending = new Map<string, PendingCommand>();
async describeCapabilities(): Promise<SidecarCapabilities> {
@@ -79,18 +105,39 @@ export class SidecarClient {
});
}
async cancelTurn(targetRequestId: string): Promise<void> {
return this.dispatch<void>({
type: 'cancel-turn',
requestId: `cancel-${Date.now()}`,
targetRequestId,
} satisfies CancelTurnCommand);
}
async dispose(): Promise<void> {
if (!this.process) {
const state = this.processState;
if (!state) {
return;
}
this.process.kill();
this.process = undefined;
state.exitExpected = true;
if (!state.child.killed && state.child.exitCode === null) {
state.child.kill();
}
await state.closed;
}
private async ensureProcess(): Promise<ChildProcessWithoutNullStreams> {
if (this.process && !this.process.killed) {
return this.process;
private async ensureProcess(): Promise<ManagedSidecarProcess> {
if (
this.processState &&
!this.processState.exitExpected &&
!this.processState.terminated &&
this.processState.child.exitCode === null
) {
return this.processState;
}
if (this.processState) {
await this.processState.closed;
}
const sidecar = resolveSidecarProcess({
@@ -105,12 +152,29 @@ export class SidecarClient {
stdio: 'pipe',
windowsHide: true,
});
this.process = childProcess;
let resolveClosed!: () => void;
const state: ManagedSidecarProcess = {
id: this.nextProcessId + 1,
child: childProcess,
stdoutBuffer: '',
exitExpected: false,
terminated: false,
closed: new Promise<void>((resolve) => {
resolveClosed = resolve;
}),
resolveClosed,
};
this.nextProcessId = state.id;
this.processState = state;
childProcess.stdout.setEncoding('utf8');
childProcess.stdout.on('data', (chunk: string) => {
this.stdoutBuffer += chunk;
this.flushStdoutBuffer();
if (state.terminated) {
return;
}
state.stdoutBuffer += chunk;
this.flushStdoutBuffer(state);
});
childProcess.stderr.setEncoding('utf8');
@@ -118,17 +182,14 @@ export class SidecarClient {
console.error('[aryx sidecar]', chunk.trim());
});
childProcess.on('exit', (code) => {
const error = new Error(`The .NET sidecar exited unexpectedly with code ${code ?? 'unknown'}.`);
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
this.process = undefined;
this.stdoutBuffer = '';
childProcess.on('close', (code) => {
const error = state.exitExpected
? new Error(SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE)
: new Error(`The .NET sidecar exited unexpectedly with code ${code ?? 'unknown'}.`);
this.handleProcessClosed(state, error);
});
return childProcess;
return state;
}
private async dispatch<TResult>(
@@ -137,11 +198,12 @@ export class SidecarClient {
onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>,
): Promise<TResult> {
const process = await this.ensureProcess();
const state = await this.ensureProcess();
return new Promise<TResult>((resolve, reject) => {
if (command.type === 'run-turn') {
this.pending.set(command.requestId, {
processId: state.id,
kind: 'run-turn',
resolve: resolve as (messages: ChatMessageRecord[]) => void,
reject,
@@ -152,46 +214,56 @@ export class SidecarClient {
});
} else if (command.type === 'validate-pattern') {
this.pending.set(command.requestId, {
processId: state.id,
kind: 'validate-pattern',
resolve: resolve as (issues: unknown) => void,
reject,
});
} else if (command.type === 'resolve-approval') {
this.pending.set(command.requestId, {
processId: state.id,
kind: 'resolve-approval',
resolve: resolve as () => void,
reject,
});
} else if (command.type === 'cancel-turn') {
this.pending.set(command.requestId, {
processId: state.id,
kind: 'cancel-turn',
resolve: resolve as () => void,
reject,
});
} else {
this.pending.set(command.requestId, {
processId: state.id,
kind: 'capabilities',
resolve: resolve as (capabilities: SidecarCapabilities) => void,
reject,
});
}
process.stdin.write(`${JSON.stringify(command)}\n`);
state.child.stdin.write(`${JSON.stringify(command)}\n`);
});
}
private flushStdoutBuffer(): void {
let newlineIndex = this.stdoutBuffer.indexOf('\n');
private flushStdoutBuffer(state: ManagedSidecarProcess): void {
let newlineIndex = state.stdoutBuffer.indexOf('\n');
while (newlineIndex >= 0) {
const rawLine = this.stdoutBuffer.slice(0, newlineIndex).trim();
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
const rawLine = state.stdoutBuffer.slice(0, newlineIndex).trim();
state.stdoutBuffer = state.stdoutBuffer.slice(newlineIndex + 1);
if (rawLine) {
this.handleEvent(JSON.parse(rawLine) as SidecarEvent);
this.handleEvent(state.id, JSON.parse(rawLine) as SidecarEvent);
}
newlineIndex = this.stdoutBuffer.indexOf('\n');
newlineIndex = state.stdoutBuffer.indexOf('\n');
}
}
private handleEvent(event: SidecarEvent): void {
private handleEvent(processId: number, event: SidecarEvent): void {
const pending = this.pending.get(event.requestId);
if (!pending) {
if (!pending || pending.processId !== processId) {
return;
}
@@ -226,7 +298,11 @@ export class SidecarClient {
case 'turn-complete':
if (pending.kind === 'run-turn') {
if (shouldHandleRunTurnEvent(pending)) {
pending.resolve(event.messages);
if (event.cancelled) {
markRunTurnPendingErrored(pending, new TurnCancelledError());
} else {
pending.resolve(event.messages);
}
}
this.pending.delete(event.requestId);
}
@@ -240,7 +316,7 @@ export class SidecarClient {
this.pending.delete(event.requestId);
return;
case 'command-complete':
if (pending.kind === 'resolve-approval') {
if (pending.kind === 'resolve-approval' || pending.kind === 'cancel-turn') {
pending.resolve();
this.pending.delete(event.requestId);
} else if (pending.kind !== 'run-turn' || pending.errored) {
@@ -262,4 +338,27 @@ export class SidecarClient {
}
});
}
private handleProcessClosed(state: ManagedSidecarProcess, error: Error): void {
if (state.terminated) {
return;
}
state.terminated = true;
if (this.processState === state) {
this.processState = undefined;
}
state.stdoutBuffer = '';
state.resolveClosed();
for (const [requestId, pending] of this.pending.entries()) {
if (pending.processId !== state.id) {
continue;
}
pending.reject(error);
this.pending.delete(requestId);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
export class TurnCancelledError extends Error {
constructor() {
super('The turn was cancelled.');
this.name = 'TurnCancelledError';
}
}
+1
View File
@@ -32,6 +32,7 @@ const api: ElectronApi = {
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input),
resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input),
updateScratchpadSessionConfig: (input) =>
ipcRenderer.invoke(ipcChannels.updateScratchpadSessionConfig, input),
+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 {
+1
View File
@@ -24,6 +24,7 @@ export const ipcChannels = {
setSessionPinned: 'sessions:set-pinned',
setSessionArchived: 'sessions:set-archived',
sendSessionMessage: 'sessions:send-message',
cancelSessionTurn: 'sessions:cancel-turn',
resolveSessionApproval: 'sessions:resolve-approval',
querySessions: 'sessions:query',
updateScratchpadSessionConfig: 'sessions:update-scratchpad-config',
+5
View File
@@ -26,6 +26,10 @@ export interface SendSessionMessageInput {
content: string;
}
export interface CancelSessionTurnInput {
sessionId: string;
}
export interface ResolveSessionApprovalInput {
sessionId: string;
approvalId: string;
@@ -120,6 +124,7 @@ export interface ElectronApi {
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
updateScratchpadSessionConfig(input: UpdateScratchpadSessionConfigInput): Promise<WorkspaceState>;
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
+8
View File
@@ -79,6 +79,12 @@ export interface RunTurnCommand {
tooling?: RunTurnToolingConfig;
}
export interface CancelTurnCommand {
type: 'cancel-turn';
requestId: string;
targetRequestId: string;
}
export interface ResolveApprovalCommand {
type: 'resolve-approval';
requestId: string;
@@ -90,6 +96,7 @@ export type SidecarCommand =
| DescribeCapabilitiesCommand
| ValidatePatternCommand
| RunTurnCommand
| CancelTurnCommand
| ResolveApprovalCommand;
export interface RunTurnLocalMcpServerConfig {
@@ -157,6 +164,7 @@ export interface TurnCompleteEvent {
requestId: string;
sessionId: string;
messages: ChatMessageRecord[];
cancelled?: boolean;
}
export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
+21 -2
View File
@@ -7,7 +7,7 @@ import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'
import type { ProjectRecord } from '@shared/domain/project';
import { createId } from '@shared/utils/ids';
export type SessionRunStatus = 'running' | 'completed' | 'error';
export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error';
export type SessionRunWorkspaceKind = 'project' | 'scratchpad';
export type RunTimelineEventKind =
| 'run-started'
@@ -17,6 +17,7 @@ export type RunTimelineEventKind =
| 'approval'
| 'message'
| 'run-completed'
| 'run-cancelled'
| 'run-failed';
export type RunTimelineEventStatus = 'running' | 'completed' | 'error';
@@ -318,7 +319,7 @@ export function normalizeSessionRunRecords(
triggerMessageId,
startedAt,
completedAt: normalizeOptionalString(run.completedAt),
status: run.status === 'error' ? 'error' : run.status === 'running' ? 'running' : 'completed',
status: run.status === 'error' ? 'error' : run.status === 'running' ? 'running' : run.status === 'cancelled' ? 'cancelled' : 'completed',
agents: run.agents.flatMap((agent) => {
const normalized = normalizeRunTimelineAgent(agent);
return normalized ? [normalized] : [];
@@ -516,6 +517,24 @@ export function completeSessionRunRecord(
});
}
export function cancelSessionRunRecord(
run: SessionRunRecord,
cancelledAt: string,
): SessionRunRecord {
const settledRun = settleOpenMessageEvents(run, 'completed', cancelledAt);
const cancelledRun: SessionRunRecord = {
...settledRun,
status: 'cancelled',
completedAt: cancelledAt,
};
return appendRunTimelineEvent(cancelledRun, {
kind: 'run-cancelled',
occurredAt: cancelledAt,
status: 'completed',
});
}
export function failSessionRunRecord(
run: SessionRunRecord,
failedAt: string,