mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-29 06:07:11 +02:00
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:
@@ -65,6 +65,7 @@ import {
|
|||||||
import { prepareChatMessageContent } from '@shared/utils/chatMessage';
|
import { prepareChatMessageContent } from '@shared/utils/chatMessage';
|
||||||
import {
|
import {
|
||||||
appendRunActivityEvent,
|
appendRunActivityEvent,
|
||||||
|
cancelSessionRunRecord,
|
||||||
completeSessionRunRecord,
|
completeSessionRunRecord,
|
||||||
createSessionRunRecord,
|
createSessionRunRecord,
|
||||||
failSessionRunRecord,
|
failSessionRunRecord,
|
||||||
@@ -99,6 +100,7 @@ import {
|
|||||||
SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE,
|
SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE,
|
||||||
SidecarClient,
|
SidecarClient,
|
||||||
} from '@main/sidecar/sidecarProcess';
|
} from '@main/sidecar/sidecarProcess';
|
||||||
|
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
|
||||||
import { GitService } from '@main/git/gitService';
|
import { GitService } from '@main/git/gitService';
|
||||||
import {
|
import {
|
||||||
buildRunTurnToolingConfig as buildSessionToolingConfig,
|
buildRunTurnToolingConfig as buildSessionToolingConfig,
|
||||||
@@ -621,6 +623,12 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
|
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
|
||||||
await this.persistAndBroadcast(workspace);
|
await this.persistAndBroadcast(workspace);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof TurnCancelledError) {
|
||||||
|
this.finalizeCancelledTurn(workspace, session, requestId);
|
||||||
|
await this.persistAndBroadcast(workspace);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const failedAt = nowIso();
|
const failedAt = nowIso();
|
||||||
session.status = 'error';
|
session.status = 'error';
|
||||||
session.lastError = error instanceof Error ? error.message : String(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(
|
async resolveSessionApproval(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
approvalId: 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(
|
private async handleApprovalRequested(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BrowserWindow, ipcMain } from 'electron';
|
|||||||
|
|
||||||
import { ipcChannels } from '@shared/contracts/channels';
|
import { ipcChannels } from '@shared/contracts/channels';
|
||||||
import type {
|
import type {
|
||||||
|
CancelSessionTurnInput,
|
||||||
CreateSessionInput,
|
CreateSessionInput,
|
||||||
ResolveProjectDiscoveredToolingInput,
|
ResolveProjectDiscoveredToolingInput,
|
||||||
ResolveWorkspaceDiscoveredToolingInput,
|
ResolveWorkspaceDiscoveredToolingInput,
|
||||||
@@ -100,6 +101,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi
|
|||||||
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
|
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
|
||||||
service.sendSessionMessage(input.sessionId, input.content),
|
service.sendSessionMessage(input.sessionId, input.content),
|
||||||
);
|
);
|
||||||
|
ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) =>
|
||||||
|
service.cancelSessionTurn(input.sessionId),
|
||||||
|
);
|
||||||
ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) =>
|
ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) =>
|
||||||
service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision),
|
service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|||||||
import type {
|
import type {
|
||||||
AgentActivityEvent,
|
AgentActivityEvent,
|
||||||
ApprovalRequestedEvent,
|
ApprovalRequestedEvent,
|
||||||
|
CancelTurnCommand,
|
||||||
SidecarCommand,
|
SidecarCommand,
|
||||||
SidecarCapabilities,
|
SidecarCapabilities,
|
||||||
SidecarEvent,
|
SidecarEvent,
|
||||||
@@ -19,29 +20,54 @@ import {
|
|||||||
shouldHandleRunTurnEvent,
|
shouldHandleRunTurnEvent,
|
||||||
type RunTurnPendingCommand,
|
type RunTurnPendingCommand,
|
||||||
} from '@main/sidecar/runTurnPending';
|
} from '@main/sidecar/runTurnPending';
|
||||||
|
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
|
||||||
import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime';
|
import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime';
|
||||||
|
|
||||||
type PendingCommand =
|
type PendingCommand =
|
||||||
| {
|
| ({
|
||||||
|
processId: number;
|
||||||
kind: 'capabilities';
|
kind: 'capabilities';
|
||||||
resolve: (capabilities: SidecarCapabilities) => void;
|
resolve: (capabilities: SidecarCapabilities) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
}
|
})
|
||||||
| {
|
| ({
|
||||||
|
processId: number;
|
||||||
kind: 'validate-pattern';
|
kind: 'validate-pattern';
|
||||||
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
|
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
}
|
})
|
||||||
| {
|
| ({
|
||||||
|
processId: number;
|
||||||
kind: 'resolve-approval';
|
kind: 'resolve-approval';
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
reject: (error: Error) => 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 {
|
export class SidecarClient {
|
||||||
private process?: ChildProcessWithoutNullStreams;
|
private processState?: ManagedSidecarProcess;
|
||||||
private stdoutBuffer = '';
|
private nextProcessId = 0;
|
||||||
private readonly pending = new Map<string, PendingCommand>();
|
private readonly pending = new Map<string, PendingCommand>();
|
||||||
|
|
||||||
async describeCapabilities(): Promise<SidecarCapabilities> {
|
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> {
|
async dispose(): Promise<void> {
|
||||||
if (!this.process) {
|
const state = this.processState;
|
||||||
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.process.kill();
|
state.exitExpected = true;
|
||||||
this.process = undefined;
|
if (!state.child.killed && state.child.exitCode === null) {
|
||||||
|
state.child.kill();
|
||||||
|
}
|
||||||
|
await state.closed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureProcess(): Promise<ChildProcessWithoutNullStreams> {
|
private async ensureProcess(): Promise<ManagedSidecarProcess> {
|
||||||
if (this.process && !this.process.killed) {
|
if (
|
||||||
return this.process;
|
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({
|
const sidecar = resolveSidecarProcess({
|
||||||
@@ -105,12 +152,29 @@ export class SidecarClient {
|
|||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
windowsHide: true,
|
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.setEncoding('utf8');
|
||||||
childProcess.stdout.on('data', (chunk: string) => {
|
childProcess.stdout.on('data', (chunk: string) => {
|
||||||
this.stdoutBuffer += chunk;
|
if (state.terminated) {
|
||||||
this.flushStdoutBuffer();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.stdoutBuffer += chunk;
|
||||||
|
this.flushStdoutBuffer(state);
|
||||||
});
|
});
|
||||||
|
|
||||||
childProcess.stderr.setEncoding('utf8');
|
childProcess.stderr.setEncoding('utf8');
|
||||||
@@ -118,17 +182,14 @@ export class SidecarClient {
|
|||||||
console.error('[aryx sidecar]', chunk.trim());
|
console.error('[aryx sidecar]', chunk.trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
childProcess.on('exit', (code) => {
|
childProcess.on('close', (code) => {
|
||||||
const error = new Error(`The .NET sidecar exited unexpectedly with code ${code ?? 'unknown'}.`);
|
const error = state.exitExpected
|
||||||
for (const pending of this.pending.values()) {
|
? new Error(SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE)
|
||||||
pending.reject(error);
|
: new Error(`The .NET sidecar exited unexpectedly with code ${code ?? 'unknown'}.`);
|
||||||
}
|
this.handleProcessClosed(state, error);
|
||||||
this.pending.clear();
|
|
||||||
this.process = undefined;
|
|
||||||
this.stdoutBuffer = '';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return childProcess;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async dispatch<TResult>(
|
private async dispatch<TResult>(
|
||||||
@@ -137,11 +198,12 @@ export class SidecarClient {
|
|||||||
onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
|
onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
|
||||||
onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
||||||
): Promise<TResult> {
|
): Promise<TResult> {
|
||||||
const process = await this.ensureProcess();
|
const state = await this.ensureProcess();
|
||||||
|
|
||||||
return new Promise<TResult>((resolve, reject) => {
|
return new Promise<TResult>((resolve, reject) => {
|
||||||
if (command.type === 'run-turn') {
|
if (command.type === 'run-turn') {
|
||||||
this.pending.set(command.requestId, {
|
this.pending.set(command.requestId, {
|
||||||
|
processId: state.id,
|
||||||
kind: 'run-turn',
|
kind: 'run-turn',
|
||||||
resolve: resolve as (messages: ChatMessageRecord[]) => void,
|
resolve: resolve as (messages: ChatMessageRecord[]) => void,
|
||||||
reject,
|
reject,
|
||||||
@@ -152,46 +214,56 @@ export class SidecarClient {
|
|||||||
});
|
});
|
||||||
} else if (command.type === 'validate-pattern') {
|
} else if (command.type === 'validate-pattern') {
|
||||||
this.pending.set(command.requestId, {
|
this.pending.set(command.requestId, {
|
||||||
|
processId: state.id,
|
||||||
kind: 'validate-pattern',
|
kind: 'validate-pattern',
|
||||||
resolve: resolve as (issues: unknown) => void,
|
resolve: resolve as (issues: unknown) => void,
|
||||||
reject,
|
reject,
|
||||||
});
|
});
|
||||||
} else if (command.type === 'resolve-approval') {
|
} else if (command.type === 'resolve-approval') {
|
||||||
this.pending.set(command.requestId, {
|
this.pending.set(command.requestId, {
|
||||||
|
processId: state.id,
|
||||||
kind: 'resolve-approval',
|
kind: 'resolve-approval',
|
||||||
resolve: resolve as () => void,
|
resolve: resolve as () => void,
|
||||||
reject,
|
reject,
|
||||||
});
|
});
|
||||||
|
} else if (command.type === 'cancel-turn') {
|
||||||
|
this.pending.set(command.requestId, {
|
||||||
|
processId: state.id,
|
||||||
|
kind: 'cancel-turn',
|
||||||
|
resolve: resolve as () => void,
|
||||||
|
reject,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
this.pending.set(command.requestId, {
|
this.pending.set(command.requestId, {
|
||||||
|
processId: state.id,
|
||||||
kind: 'capabilities',
|
kind: 'capabilities',
|
||||||
resolve: resolve as (capabilities: SidecarCapabilities) => void,
|
resolve: resolve as (capabilities: SidecarCapabilities) => void,
|
||||||
reject,
|
reject,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
process.stdin.write(`${JSON.stringify(command)}\n`);
|
state.child.stdin.write(`${JSON.stringify(command)}\n`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private flushStdoutBuffer(): void {
|
private flushStdoutBuffer(state: ManagedSidecarProcess): void {
|
||||||
let newlineIndex = this.stdoutBuffer.indexOf('\n');
|
let newlineIndex = state.stdoutBuffer.indexOf('\n');
|
||||||
|
|
||||||
while (newlineIndex >= 0) {
|
while (newlineIndex >= 0) {
|
||||||
const rawLine = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
const rawLine = state.stdoutBuffer.slice(0, newlineIndex).trim();
|
||||||
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
state.stdoutBuffer = state.stdoutBuffer.slice(newlineIndex + 1);
|
||||||
|
|
||||||
if (rawLine) {
|
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);
|
const pending = this.pending.get(event.requestId);
|
||||||
if (!pending) {
|
if (!pending || pending.processId !== processId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +298,11 @@ export class SidecarClient {
|
|||||||
case 'turn-complete':
|
case 'turn-complete':
|
||||||
if (pending.kind === 'run-turn') {
|
if (pending.kind === 'run-turn') {
|
||||||
if (shouldHandleRunTurnEvent(pending)) {
|
if (shouldHandleRunTurnEvent(pending)) {
|
||||||
pending.resolve(event.messages);
|
if (event.cancelled) {
|
||||||
|
markRunTurnPendingErrored(pending, new TurnCancelledError());
|
||||||
|
} else {
|
||||||
|
pending.resolve(event.messages);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.pending.delete(event.requestId);
|
this.pending.delete(event.requestId);
|
||||||
}
|
}
|
||||||
@@ -240,7 +316,7 @@ export class SidecarClient {
|
|||||||
this.pending.delete(event.requestId);
|
this.pending.delete(event.requestId);
|
||||||
return;
|
return;
|
||||||
case 'command-complete':
|
case 'command-complete':
|
||||||
if (pending.kind === 'resolve-approval') {
|
if (pending.kind === 'resolve-approval' || pending.kind === 'cancel-turn') {
|
||||||
pending.resolve();
|
pending.resolve();
|
||||||
this.pending.delete(event.requestId);
|
this.pending.delete(event.requestId);
|
||||||
} else if (pending.kind !== 'run-turn' || pending.errored) {
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class TurnCancelledError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('The turn was cancelled.');
|
||||||
|
this.name = 'TurnCancelledError';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ const api: ElectronApi = {
|
|||||||
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
|
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
|
||||||
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
|
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
|
||||||
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
|
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
|
||||||
|
cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input),
|
||||||
resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input),
|
resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input),
|
||||||
updateScratchpadSessionConfig: (input) =>
|
updateScratchpadSessionConfig: (input) =>
|
||||||
ipcRenderer.invoke(ipcChannels.updateScratchpadSessionConfig, input),
|
ipcRenderer.invoke(ipcChannels.updateScratchpadSessionConfig, input),
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ 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 })}
|
||||||
|
onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }}
|
||||||
onResolveApproval={(approvalId, decision) =>
|
onResolveApproval={(approvalId, decision) =>
|
||||||
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision })
|
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||||
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
|
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
|
||||||
@@ -34,6 +34,7 @@ interface ChatPaneProps {
|
|||||||
toolingSettings: WorkspaceToolingSettings;
|
toolingSettings: WorkspaceToolingSettings;
|
||||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||||
onSend: (content: string) => Promise<void>;
|
onSend: (content: string) => Promise<void>;
|
||||||
|
onCancelTurn?: () => void;
|
||||||
onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>;
|
onResolveApproval?: (approvalId: string, decision: ApprovalDecision) => Promise<unknown>;
|
||||||
onUpdateScratchpadConfig?: (config: {
|
onUpdateScratchpadConfig?: (config: {
|
||||||
model: string;
|
model: string;
|
||||||
@@ -51,6 +52,7 @@ export function ChatPane({
|
|||||||
toolingSettings,
|
toolingSettings,
|
||||||
runtimeTools,
|
runtimeTools,
|
||||||
onSend,
|
onSend,
|
||||||
|
onCancelTurn,
|
||||||
onResolveApproval,
|
onResolveApproval,
|
||||||
onUpdateScratchpadConfig,
|
onUpdateScratchpadConfig,
|
||||||
onUpdateSessionTooling,
|
onUpdateSessionTooling,
|
||||||
@@ -432,16 +434,25 @@ export function ChatPane({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={`absolute bottom-2 right-2 flex size-8 items-center justify-center rounded-lg transition ${
|
className={`absolute bottom-2 right-2 flex size-8 items-center justify-center rounded-lg transition ${
|
||||||
canSubmitInput
|
isSessionBusy
|
||||||
? 'bg-indigo-600 text-white hover:bg-indigo-500'
|
? 'bg-red-600/80 text-white hover:bg-red-500'
|
||||||
: 'bg-zinc-800 text-zinc-600'
|
: canSubmitInput
|
||||||
|
? 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||||
|
: 'bg-zinc-800 text-zinc-600'
|
||||||
}`}
|
}`}
|
||||||
disabled={!canSubmitInput}
|
disabled={!canSubmitInput && !isSessionBusy}
|
||||||
onClick={() => composerRef.current?.submit()}
|
onClick={() => {
|
||||||
|
if (isSessionBusy) {
|
||||||
|
onCancelTurn?.();
|
||||||
|
} else {
|
||||||
|
composerRef.current?.submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={isSessionBusy ? 'Stop generating' : 'Send message'}
|
||||||
>
|
>
|
||||||
{isSessionBusy ? (
|
{isSessionBusy ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Square className="size-3.5" fill="currentColor" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowUp className="size-4" />
|
<ArrowUp className="size-4" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const modeAccent: Record<OrchestrationMode, { dot: string; ring: string; text: s
|
|||||||
const runStatusStyles: Record<SessionRunRecord['status'], { icon: ReactNode; className: string }> = {
|
const runStatusStyles: Record<SessionRunRecord['status'], { icon: ReactNode; className: string }> = {
|
||||||
running: { icon: <CircleDot className="size-3" />, className: 'text-blue-400' },
|
running: { icon: <CircleDot className="size-3" />, className: 'text-blue-400' },
|
||||||
completed: { icon: <CheckCircle2 className="size-3" />, className: 'text-emerald-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' },
|
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'}`} />;
|
return <MessageSquare className={`${base} ${status === 'running' ? 'text-blue-400 animate-pulse' : status === 'error' ? 'text-red-400' : 'text-emerald-400'}`} />;
|
||||||
case 'run-completed':
|
case 'run-completed':
|
||||||
return <CheckCircle2 className={`${base} text-emerald-400`} />;
|
return <CheckCircle2 className={`${base} text-emerald-400`} />;
|
||||||
|
case 'run-cancelled':
|
||||||
|
return <XCircle className={`${base} text-zinc-400`} />;
|
||||||
case 'run-failed':
|
case 'run-failed':
|
||||||
return <AlertTriangle className={`${base} text-red-400`} />;
|
return <AlertTriangle className={`${base} text-red-400`} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export function formatRunStatusLabel(status: SessionRunStatus): string {
|
|||||||
return 'Running';
|
return 'Running';
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return 'Completed';
|
return 'Completed';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'Cancelled';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'Failed';
|
return 'Failed';
|
||||||
}
|
}
|
||||||
@@ -91,6 +93,8 @@ export function formatEventLabel(event: RunTimelineEventRecord): string {
|
|||||||
return event.agentName ?? 'Response';
|
return event.agentName ?? 'Response';
|
||||||
case 'run-completed':
|
case 'run-completed':
|
||||||
return 'Completed';
|
return 'Completed';
|
||||||
|
case 'run-cancelled':
|
||||||
|
return 'Cancelled';
|
||||||
case 'run-failed':
|
case 'run-failed':
|
||||||
return 'Failed';
|
return 'Failed';
|
||||||
}
|
}
|
||||||
@@ -144,11 +148,12 @@ const eventKindOrder: Record<RunTimelineEventKind, number> = {
|
|||||||
'approval': 4,
|
'approval': 4,
|
||||||
'message': 5,
|
'message': 5,
|
||||||
'run-completed': 6,
|
'run-completed': 6,
|
||||||
|
'run-cancelled': 6,
|
||||||
'run-failed': 6,
|
'run-failed': 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isTerminalEvent(kind: RunTimelineEventKind): boolean {
|
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 {
|
export function eventSortKey(event: RunTimelineEventRecord): number {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const ipcChannels = {
|
|||||||
setSessionPinned: 'sessions:set-pinned',
|
setSessionPinned: 'sessions:set-pinned',
|
||||||
setSessionArchived: 'sessions:set-archived',
|
setSessionArchived: 'sessions:set-archived',
|
||||||
sendSessionMessage: 'sessions:send-message',
|
sendSessionMessage: 'sessions:send-message',
|
||||||
|
cancelSessionTurn: 'sessions:cancel-turn',
|
||||||
resolveSessionApproval: 'sessions:resolve-approval',
|
resolveSessionApproval: 'sessions:resolve-approval',
|
||||||
querySessions: 'sessions:query',
|
querySessions: 'sessions:query',
|
||||||
updateScratchpadSessionConfig: 'sessions:update-scratchpad-config',
|
updateScratchpadSessionConfig: 'sessions:update-scratchpad-config',
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export interface SendSessionMessageInput {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CancelSessionTurnInput {
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolveSessionApprovalInput {
|
export interface ResolveSessionApprovalInput {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
approvalId: string;
|
approvalId: string;
|
||||||
@@ -120,6 +124,7 @@ export interface ElectronApi {
|
|||||||
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
|
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
|
||||||
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
|
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
|
||||||
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
|
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
|
||||||
|
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
|
||||||
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
|
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
|
||||||
updateScratchpadSessionConfig(input: UpdateScratchpadSessionConfigInput): Promise<WorkspaceState>;
|
updateScratchpadSessionConfig(input: UpdateScratchpadSessionConfigInput): Promise<WorkspaceState>;
|
||||||
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
|
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ export interface RunTurnCommand {
|
|||||||
tooling?: RunTurnToolingConfig;
|
tooling?: RunTurnToolingConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CancelTurnCommand {
|
||||||
|
type: 'cancel-turn';
|
||||||
|
requestId: string;
|
||||||
|
targetRequestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolveApprovalCommand {
|
export interface ResolveApprovalCommand {
|
||||||
type: 'resolve-approval';
|
type: 'resolve-approval';
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -90,6 +96,7 @@ export type SidecarCommand =
|
|||||||
| DescribeCapabilitiesCommand
|
| DescribeCapabilitiesCommand
|
||||||
| ValidatePatternCommand
|
| ValidatePatternCommand
|
||||||
| RunTurnCommand
|
| RunTurnCommand
|
||||||
|
| CancelTurnCommand
|
||||||
| ResolveApprovalCommand;
|
| ResolveApprovalCommand;
|
||||||
|
|
||||||
export interface RunTurnLocalMcpServerConfig {
|
export interface RunTurnLocalMcpServerConfig {
|
||||||
@@ -157,6 +164,7 @@ export interface TurnCompleteEvent {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
messages: ChatMessageRecord[];
|
messages: ChatMessageRecord[];
|
||||||
|
cancelled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'
|
|||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import { createId } from '@shared/utils/ids';
|
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 SessionRunWorkspaceKind = 'project' | 'scratchpad';
|
||||||
export type RunTimelineEventKind =
|
export type RunTimelineEventKind =
|
||||||
| 'run-started'
|
| 'run-started'
|
||||||
@@ -17,6 +17,7 @@ export type RunTimelineEventKind =
|
|||||||
| 'approval'
|
| 'approval'
|
||||||
| 'message'
|
| 'message'
|
||||||
| 'run-completed'
|
| 'run-completed'
|
||||||
|
| 'run-cancelled'
|
||||||
| 'run-failed';
|
| 'run-failed';
|
||||||
export type RunTimelineEventStatus = 'running' | 'completed' | 'error';
|
export type RunTimelineEventStatus = 'running' | 'completed' | 'error';
|
||||||
|
|
||||||
@@ -318,7 +319,7 @@ export function normalizeSessionRunRecords(
|
|||||||
triggerMessageId,
|
triggerMessageId,
|
||||||
startedAt,
|
startedAt,
|
||||||
completedAt: normalizeOptionalString(run.completedAt),
|
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) => {
|
agents: run.agents.flatMap((agent) => {
|
||||||
const normalized = normalizeRunTimelineAgent(agent);
|
const normalized = normalizeRunTimelineAgent(agent);
|
||||||
return normalized ? [normalized] : [];
|
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(
|
export function failSessionRunRecord(
|
||||||
run: SessionRunRecord,
|
run: SessionRunRecord,
|
||||||
failedAt: string,
|
failedAt: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user