diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index e79edd0c3e..ab9c80f170 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -570,12 +570,10 @@ export class AIChatManager implements ChatViewHost { * chat card holds. Unset outside a session: a chat-bound form has nowhere else to go, * so the card hides the control rather than offering a tab that cannot run. */ openRunForm?: (a: { toolCallId: string; label: string }) => void - /** A DEPLOYED run shows the item's own page instead, with the model's arguments in the - * run form already there. The tool call ends at the open — the run belongs to the reader, - * who starts it from that page with its scheduling, tag and version controls in reach. - * Returns whether the panel took it; false (a legacy app has no in-panel page) leaves the - * tool to fall back on its own form. */ - openDeployedRunPage?: (a: { + /** Set by a host that can show a deployed page — a session's preview panel. Tools reach it + * through {@link openDeployedRunPage}, never directly: the stop check belongs to the + * manager, which owns the turn. */ + openDeployedRunPageHandler?: (a: { kind: 'script' | 'flow' path: string summary: string @@ -2100,6 +2098,25 @@ export class AIChatManager implements ChatViewHost { /** False when the form is no longer pending, so the caller can say so instead of * leaving its submit button spinning on a run that will never start. */ + /** A DEPLOYED run shows the item's own page instead, with the model's arguments in the run + * form already there. The tool call ends at the open — the run belongs to the reader, who + * starts it from that page with its scheduling, tag and version controls in reach. + * + * False where there is no page to show (no session host) and where the turn was stopped + * while the tool read the item's schema: opening then would act on a turn the reader + * ended, and would write a form onto a card the stop already settled, which the card would + * read as `Opened`. Declining hands the call on to `requestRunArgs`, whose own stop guard + * settles it as cancelled. */ + openDeployedRunPage = (a: { + kind: 'script' | 'flow' + path: string + summary: string + args: Record + }): boolean => { + if (this.abortController?.signal.aborted) return false + return this.openDeployedRunPageHandler?.(a) ?? false + } + handleRunFormSubmit = (toolId: string, args: Record): boolean => { if (!this.isRunFormPending(toolId)) return false this.#settleRunForm(toolId, args) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 107970186b..ff627c3319 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -6045,7 +6045,7 @@ async function testRunScriptByPath( * same reason as the cancelled one below — a re-proposal would reopen the page the reader is * already looking at. */ const deployedPageOpened = (path: string, noun: string, toolName: string) => - `Opened the deployed page for "${path}" in the preview panel, with these arguments filled into its run form. The ${noun} has NOT run: the user starts it from that page, where they can also edit the arguments, schedule it, override the tag or pick a version. Do not call ${toolName} again for this. Tell the user it is ready to run; if you need the result afterwards, ask them or call get_run once they say it has run.` + `Opened the deployed page for "${path}" in the preview panel, with these arguments put in front of the user there. The ${noun} has NOT run: the user starts it from that page, where they can also edit the arguments, schedule it, override the tag or pick a version. Do not call ${toolName} again for this. Tell the user it is ready to run; if you need the result afterwards, ask them or call get_run once they say it has run.` /** The "do not call again" half is load-bearing: without it the model re-proposes the * call, which re-opens the form the user just dismissed, and Stop becomes their only @@ -6208,8 +6208,11 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise void /** Show a DEPLOYED run on the item's own page, with these arguments in the run form that * is already there, and end the call: the reader runs it from that page rather than - * confirming a form the tool would then run itself. Returns whether the host took it. - * Wired only where that page can be shown — a session's preview panel. */ + * confirming a form the tool would then run itself. Wired only where that page can be + * shown — a session's preview panel. + * + * False means it did not open, and the caller must fall through to its own form: there is + * no session to show it in, or the turn was stopped while this tool read the item's + * schema. The form path settles a stopped turn correctly; this one has no card left to + * settle. */ openDeployedRunPage?: (a: { kind: 'script' | 'flow' path: string diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 35f168c580..bae6e9f314 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -374,28 +374,22 @@ $effect(() => { if (!seededRun || seededSeq === seededRun.seq) return if (chatInputEnabled) { - const message = seededRun.args?.user_message - // Nothing to offer is settled, not pending: latch it so a flow whose schema has no - // `user_message` does not re-ask on every flush. - if (typeof message !== 'string' || !message) { - seededSeq = seededRun.seq - return - } // The composer mounts a flush or two after the panel does, so until it is there this // effect waits — it reads the host's registration through `composerReady`, and runs // again when that lands. Not latching is what keeps the proposal alive across those // flushes. if (!flowChat?.composerReady()) return - // Past that point the answer is final either way. The rest of the proposal goes with - // the message: a chat flow's schema can declare more than `user_message`, and sending - // the message alone would run on saved values or schema defaults — a different run - // from the one being offered. A composer already holding a draft declines, and the - // proposal is dropped rather than held: injecting it whenever the reader happens to - // clear their draft would put words in the box long after they were proposed. The - // card still shows what was asked for. - const { user_message: _m, ...inputs } = seededRun.args ?? {} seededSeq = seededRun.seq - flowChat.offerMessage(message, inputs) + // A chat flow's schema can declare more than `user_message`, and a message sent + // without the rest would run on saved values or schema defaults — a different run + // from the one proposed. These land whether or not the message does. + const { user_message: message, ...inputs } = seededRun.args ?? {} + flowChat.applyInputs(inputs) + // A composer already holding a draft declines, and the message is then dropped + // rather than held: injecting it whenever the reader happens to clear their draft + // would put words in the box long after they were proposed. The card still shows + // what was asked for. + if (typeof message === 'string' && message) flowChat.offerMessage(message) } else if (runForm) { seededSeq = seededRun.seq runForm.setArgs(seededRun.args) diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index 5820e4bef4..9d4b0e7b94 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte @@ -186,11 +186,17 @@ * `user_message`, and a message sent without them would run on saved values or schema * defaults — a different run from the one being offered. Applied only alongside a message * the composer took, so a declined offer leaves the reader's settings alone. */ - export function offerMessage(text: string, inputs?: Record): boolean { + export function offerMessage(text: string): boolean { const panel = shownKey ? pool?.get(shownKey) : undefined - if (!panel?.host.offerMessage(text)) return false - if (inputs) inputValues = { ...inputValues, ...inputs } - return true + return panel?.host.offerMessage(text) ?? false + } + + /** Set the flow's non-message inputs for the run a chat tool proposed. Separate from the + * message because the two can land apart: a composer the reader is typing in declines the + * message, and the inputs still belong to the proposal they can send afterwards. */ + export function applyInputs(inputs: Record): void { + if (Object.keys(inputs).length === 0) return + inputValues = { ...inputValues, ...inputs } } /** Whether the conversation on screen has a composer to offer a message to. A caller that diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte index 1ee2e847af..401c56bfbe 100644 --- a/frontend/src/lib/components/sessions/ItemViewerView.svelte +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -72,13 +72,20 @@ // Taken from the runtime once and held here: the request belongs to the turn that made it, // so leaving it in the map would re-apply a stale proposal — and reannounce it — to every // later open of this item's deployed page. - let seededRun = $state<{ args: Record; seq: number } | undefined>(undefined) + // Carries the item it was filed under: this tab re-points to another item in place rather + // than remounting, so a seed held without its key would be pushed into the next item's + // form — replacing its own defaults — under a note crediting the agent for them. + let held = $state< + { kind: SessionTargetKind; path: string; args: Record; seq: number } | undefined + >(undefined) + const seededRun = $derived(held?.kind === kind && held.path === path ? held : undefined) $effect(() => { const pending = runtime.seededRunArgsFor(kind, path) if (!pending) return + const [k, p] = [kind, path] untrack(() => { - seededRun = pending - runtime.clearSeededRunArgs(kind, path) + held = { kind: k, path: p, ...pending } + runtime.clearSeededRunArgs(k, p) }) }) @@ -221,7 +228,7 @@ {active} embedded {seededRun} - onClearSeededRun={() => (seededRun = undefined)} + onClearSeededRun={() => (held = undefined)} onLoadState={setLoadState} /> {:else if kind === 'flow'} @@ -233,7 +240,7 @@ {active} embedded {seededRun} - onClearSeededRun={() => (seededRun = undefined)} + onClearSeededRun={() => (held = undefined)} onLoadState={setLoadState} /> {:else} diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index a0f792c24b..73f0e90e18 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -616,10 +616,11 @@ function createRuntime(session: Session): SessionRuntime { // into the run form that is already there. Nothing waits on it: the tool call ends at the // open, and the run is the reader's, started from that page as it would be with no chat // open. `seq` rather than a flag, so a second request for a page already open re-seeds it. - manager.openDeployedRunPage = ({ kind, path, summary, args }) => { + manager.openDeployedRunPageHandler = ({ kind, path, summary, args }) => { // Through the shared adapter rather than building the item here: a session target // spells a code-based app `raw_app` and a workspace item spells it `app` with a - // flag, and one place should know that. + // flag, and one place should know that. A script and a flow both resolve to an item, + // so this guard is the adapter's contract rather than a case that happens. const target = previewTargetForSessionTarget(kind, path) if (target?.type !== 'item') return false seededRunArgs.set(seededKey(kind, path), { args, seq: ++seededRunSeq })