diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 45f0713405..76e779a45c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -4594,9 +4594,16 @@ 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 job is queued the script - // is running for real, so the card must not claim it was canceled. - const ranAlready = message.runForm?.started === true + // Stopping the turn does not stop the job, and between Run and the job's id + // there is no way to know whether the server queued one: nothing threads the + // abort into that request, so it lands either way. That window says so + // rather than picking a side — "canceled" hides a script that ran, "started" + // invents one that did not. + const runState = message.runForm?.started + ? 'started' + : message.runForm?.submitted + ? 'starting' + : 'idle' return { ...message, isLoading: false, @@ -4611,17 +4618,21 @@ export class AIChatManager { content: message.userQuestion ? `Asked: ${message.userQuestion.question} — ${messageText}` : message.runForm - ? ranAlready + ? runState === 'started' ? `Run ${message.runForm.path} — started, stopped tracking before it finished` - : `Run ${message.runForm.path} — ${messageText}` + : runState === 'starting' + ? `Run ${message.runForm.path} — ${messageText} while starting, check the runs page for a job` + : `Run ${message.runForm.path} — ${messageText}` : messageText, - // A started run keeps whatever the job reported: it is not this turn's - // error, and the jobs tray is still following it. - ...(ranAlready ? {} : { error: messageText }), + // A run that reached the server keeps whatever the job reported: it is not + // this turn's error, and the jobs tray is still following it. + ...(runState === 'idle' ? { error: messageText } : {}), userQuestion: message.userQuestion ? { ...message.userQuestion, canceled: true } : undefined, - runForm: message.runForm ? { ...message.runForm, canceled: !ranAlready } : undefined + runForm: message.runForm + ? { ...message.runForm, canceled: runState === 'idle' } + : undefined } } return message diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index a10cdba405..064d9b2764 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -302,9 +302,9 @@ describe('AIChatManager run form', () => { 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', () => { + // Run flips `submitted` a round trip before the job id arrives, and nothing threads + // the stop into that request — so the card claims neither outcome for that window. + it('claims neither outcome for a run stopped while its job was starting', () => { const manager = new AIChatManager() manager.displayMessages = [ { @@ -318,6 +318,29 @@ describe('AIChatManager run form', () => { manager.cancelLoadingTools() + const settled = manager.displayMessages[0] + expect(settled.runForm?.canceled).toBe(false) + expect(settled.error).toBe(undefined) + expect(settled.content).toBe( + 'Run f/a/b — Canceled while starting, check the runs page for a job' + ) + }) + + // Only a form the user never submitted was cancelled outright. + it('marks an unsubmitted run cancelled when the turn is stopped', () => { + const manager = new AIChatManager() + manager.displayMessages = [ + { + role: 'tool', + tool_call_id: 'call_u', + content: 'Waiting for you to confirm the arguments of "f/a/b"', + isLoading: true, + runForm: { path: 'f/a/b', schema: {}, args: {} } + } + ] + + manager.cancelLoadingTools() + const settled = manager.displayMessages[0] 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 2306a805b5..ebc5ec801b 100644 --- a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte @@ -25,14 +25,17 @@ // variables have to resolve there too. const workspace = $derived(aiChatManager.operatingWorkspace) - const properties = $derived(runForm.schema?.properties ?? {}) - const hasArgs = $derived(Object.keys(properties).length > 0) - - // Deep copy, not a spread: runForm comes off displayMessages ($state), so its nested + // Deep copies, not spreads: runForm comes off displayMessages ($state), so its nested // values are proxies that $state() hands back untouched. SchemaForm edits objects and // arrays in place, so a shallow copy would write every keystroke — a password typed - // into a nested field included — straight into the persisted transcript. + // into a nested field included — straight into the persisted transcript. The schema + // goes the same way: SchemaForm binds it and reorders its properties on mount. let args = $state($state.snapshot(runForm.args ?? {}) as Record) + let schema = $state($state.snapshot(runForm.schema) as Record) + + const properties = $derived(schema?.properties ?? {}) + const hasArgs = $derived(Object.keys(properties).length > 0) + let isValid = $state(true) let submitting = $state(false) let cardNode = $state() @@ -56,8 +59,8 @@ processed = await processSecretArgs( // Last gate before the job: what the card showed is what runs, conformed the // same way the prefill was. - conformArgsToSchema(args ?? {}, runForm.schema).args, - runForm.schema as any, + conformArgsToSchema(args ?? {}, schema).args, + schema as any, workspace ) } catch (e) { @@ -96,7 +99,7 @@
{#if hasArgs} {runForm.resetKeys.join(', ')}

{/if} + {#if runForm.strippedKeys?.length} +

+ A secret or a file, so it opened empty for you to fill in: + {runForm.strippedKeys.join(', ')} +

+ {/if}