diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index a0f46cb650..8d71744848 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -224,12 +224,13 @@ const focusOnChat = !active || active === document.body || (panelEl?.contains(active) ?? false) if (!focusOnChat) return - // The run form parks the loop on the user, so an Escape aimed at one of its - // fields must not discard what they typed. Only the fields: from its buttons - // Escape still stops the turn, which is the way out while a submit is in flight. + // The run form parks the loop on the user, so an Escape aimed at it must not + // discard what they typed — its widgets are buttons as often as fields (a oneOf + // branch toggle, add-item, the pickers). Only the action row still stops the + // turn, which is the way out while a submit is in flight. if ( active?.closest('[data-chat-keyboard-scope="run-args-form"]') && - active.matches('input, textarea, select, [contenteditable]') + !active.closest('[data-run-form-actions]') ) { return } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 8b7411d363..45f0713405 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -1798,6 +1798,8 @@ export class AIChatManager { }) } + markRunFormStarted = (toolId: string) => this.#patchRunForm(toolId, { started: true }) + // A form restored from history has no callback: the loop that opened it is gone. isRunFormPending = (toolId: string): boolean => this.runFormCallbacks.has(toolId) @@ -3762,6 +3764,7 @@ export class AIChatManager { onToolBlockedByPlanMode: this.planMode.noteBlockedTool, requestUserQuestion: this.requestUserQuestion, requestRunArgs: this.requestRunArgs, + markRunFormStarted: this.markRunFormStarted, onItemModified: (kind, path) => this.recordModifiedItem(kind, path), onItemDeployed: (kind, from, to) => void this.renameModifiedItem(kind, from, to), onItemDiscarded: (kind, path) => void this.removeModifiedItem(kind, path), @@ -4591,9 +4594,9 @@ export class AIChatManager { ): DisplayMessage[] => messages.map((message) => { if (message.role === 'tool' && (message.isLoading || message.isQueued)) { - // Stopping the turn does not stop the job: once the form was submitted the - // script is running for real, so the card must not claim it was canceled. - const ranAlready = message.runForm?.submitted === true + // Stopping the turn does not stop the job: once the job is queued the script + // is running for real, so the card must not claim it was canceled. + const ranAlready = message.runForm?.started === true return { ...message, isLoading: false, diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 1be5b08b66..a10cdba405 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -282,7 +282,29 @@ describe('AIChatManager run form', () => { // Stop ends the turn, not the job: the deployed script is already running with all // its side effects, so the transcript must not record it as cancelled. - it('does not mark a submitted run cancelled when the turn is stopped', () => { + it('does not mark a started run cancelled when the turn is stopped', () => { + const manager = new AIChatManager() + manager.displayMessages = [ + { + role: 'tool', + tool_call_id: 'call_s', + content: 'Running "f/a/b"...', + isLoading: true, + runForm: { path: 'f/a/b', schema: {}, args: {}, submitted: true, started: true } + } + ] + + manager.cancelLoadingTools() + + const settled = manager.displayMessages[0] + expect(settled.runForm?.canceled).toBe(false) + expect(settled.error).toBe(undefined) + expect(settled.isLoading).toBe(false) + }) + + // Run flips `submitted` a round trip before the job exists. Stop in that window + // cancelled nothing that ran, so the card must not claim the script was left running. + it('marks a submitted run cancelled while its job has not started', () => { const manager = new AIChatManager() manager.displayMessages = [ { @@ -297,9 +319,8 @@ describe('AIChatManager run form', () => { manager.cancelLoadingTools() const settled = manager.displayMessages[0] - expect(settled.runForm?.canceled).toBe(false) - expect(settled.error).toBe(undefined) - expect(settled.isLoading).toBe(false) + expect(settled.runForm?.canceled).toBe(true) + expect(settled.content).toBe('Run f/a/b — Canceled') }) }) diff --git a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte index 0bff257193..2306a805b5 100644 --- a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte @@ -123,8 +123,8 @@ -
+ starting. Marked as the one part of the form Escape still stops the turn from. --> +