diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 7bf9685e87..e79edd0c3e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -570,6 +570,17 @@ 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: { + kind: 'script' | 'flow' + path: string + summary: string + args: Record + }) => boolean closeRunForm?: (toolCallId: string) => void /** Hands that tab from the form to the run it just started, in place: the tab keeps its * position in the strip and stays active if it was. */ @@ -4149,6 +4160,7 @@ export class AIChatManager implements ChatViewHost { requestUserQuestion: this.requestUserQuestion, requestRunArgs: this.requestRunArgs, markRunFormStarted: this.markRunFormStarted, + openDeployedRunPage: this.openDeployedRunPage, 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), diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte index ca5e717891..114a49fe6b 100644 --- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte +++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte @@ -92,12 +92,24 @@ // "the run finished": a detached job keeps the card in its running state until // the background poller lands an outcome on it or the tray sees the job end. An // inspected run has no poller behind it — the status it was read at is the answer. + // The call ended by opening the item's deployed page: nothing ran, nothing is waiting, and + // the run is the reader's to start from there. An outcome of its own, so the card neither + // shimmers as if a job were in flight nor claims one finished. + const openedDeployedPage = $derived( + !pending && + !message.isLoading && + !ran && + runForm?.kind === 'run' && + !runForm?.submitted && + !runForm?.canceled + ) const settled = $derived( inspected ? ['success', 'failure', 'canceled'].includes(inspectedStatus ?? '') : !pending && !message.isLoading && - (message.result !== undefined || + (openedDeployedPage || + message.result !== undefined || failed || canceled || (chatJob !== undefined && ['success', 'failure', 'canceled'].includes(chatJob.status))) @@ -170,7 +182,15 @@ // Inspecting is done the moment the tool returned, whatever the run it looked at is // still doing — the tense belongs to the call, not to its subject. const verb = $derived( - inspected ? 'Inspected' : running ? verbs.present : settled && ran ? verbs.past : verbs.future + inspected + ? 'Inspected' + : openedDeployedPage + ? 'Opened' + : running + ? verbs.present + : settled && ran + ? verbs.past + : verbs.future ) // Being cancelled is an outcome like any other, and it is the one the card has to say out @@ -185,7 +205,10 @@ ) // Streaming opens the tab early: the result is already arriving, and one that appeared // only at the end would hide the thing the user is waiting to read. - const hasOutcome = $derived(settled || streaming) + // A call that ended by opening the page has no outcome to show — no job ran, so an outcome + // tab could only say the run returned nothing, which reads as a run that came back empty. + // The arguments it filled are the whole story, and `Inputs` carries those. + const hasOutcome = $derived(!openedDeployedPage && (settled || streaming)) const tabs = $derived([ { value: 'input', label: 'Inputs' }, ...(ran ? [{ value: 'logs', label: 'Logs' }] : []), @@ -376,6 +399,11 @@ // waiting on one, the run once a job exists. Neither, and there is nothing to open, so // the button is not drawn at all — a form has nowhere to go outside a session, and a // call cancelled before Run never became a run. + // A deployed run never waits on a form: the tool opened the item's own page and ended, so + // this reopens that page — the reader may have closed the tab, or moved on and come back. + const asDeployedPage = $derived( + openedDeployedPage && !!path && !!aiChatManager.openDeployedRunPage + ) const previewTarget = $derived( pending ? aiChatManager.openRunForm @@ -383,14 +411,18 @@ : undefined : job ? ('run' as const) - : undefined + : asDeployedPage + ? ('deployed' as const) + : undefined ) const previewTitle = $derived( previewTarget === 'form' ? `Open this form in the preview panel: ${path}` - : aiChatManager.openRunInPreview - ? `Open this run in the preview panel: ${path || runnableName}` - : `Open this run in a new tab: ${path || runnableName}` + : previewTarget === 'deployed' + ? `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'} and its run form: ${path}` + : aiChatManager.openRunInPreview + ? `Open this run in the preview panel: ${path || runnableName}` + : `Open this run in a new tab: ${path || runnableName}` ) function openPreview() { @@ -399,6 +431,15 @@ aiChatManager.openRunForm?.({ toolCallId: message.tool_call_id, label }) return } + if (previewTarget === 'deployed') { + aiChatManager.openDeployedRunPage?.({ + kind: runnableKind === 'flow' ? 'flow' : 'script', + path, + summary: label, + args: runForm?.args ?? {} + }) + return + } if (!job) return // Outside a session there is no panel, so the run opens where the jobs tray sends it. if (aiChatManager.openRunInPreview) { @@ -432,6 +473,7 @@ title={previewTitle} onOpen={openPreview} kindIcon={false} + mode={previewTarget === 'deployed' ? 'view' : 'edit'} /> {/snippet} diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 2a39ca1902..00378dbb26 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -6041,6 +6041,13 @@ async function testRunScriptByPath( /** 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 * way out. */ +/** Nothing ran, and saying so is the whole point: the model is about to report back, and a + * "started" it inferred would be wrong. The "do not call again" half is load-bearing for the + * same reason as the cancelled one — 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.` + const runFormCancelled = (toolName: string, noun: string) => `The user cancelled the run form. The ${noun} did NOT run. Do not call ${toolName} again unless the user asks for it.` @@ -6179,6 +6186,38 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise Promise | undefined> /** The submitted form's job is queued. Wired alongside requestRunArgs. */ markRunFormStarted?: (toolId: string) => 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. */ + openDeployedRunPage?: (a: { + kind: 'script' | 'flow' + path: string + summary: string + args: Record + }) => boolean /** Records a workspace item the tool call created/edited/deleted, by its * canonical (itemKind, storagePath). Session chats wire this to accumulate the * chat's modified-items mask; the global side-panel chat omits it (no-op). */ diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 7001075e93..ab475450c7 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -101,6 +101,8 @@ onNavigate = goto, active = true, embedded = false, + seededRun, + onClearSeededRun, onLoadState }: { /** The `[...path]` route segment: the flow's path. */ @@ -116,6 +118,13 @@ /** Rendered inside a page that is not the flow's own (an AI session preview tab), whose * URL this must leave alone and which a cross-workspace link must not navigate away. */ embedded?: boolean + /** Arguments a chat tool filled this form with when it opened the page. Nothing waits + * on them: the page runs as it always does, and these only seed the fields and put a + * note above them. `seq` identifies the request, so a second one re-seeds a page that + * is already open. */ + seededRun?: { args: Record; seq: number } + /** The reader dismissed that note. The values stay in the fields. */ + onClearSeededRun?: () => void /** How the load ended, for a host that renders its own state around this page. */ onLoadState?: (state: 'loaded' | 'not_found') => void } = $props() @@ -352,6 +361,30 @@ let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state() let runForm: RunForm | undefined = $state() + // Seeded once the surface exists, and again whenever another request arrives for a page + // already open — latched on the request rather than on "seeded once", which would leave + // the previous turn's values on screen. The arguments were narrowed against this flow's + // deployed schema before they got here, by the tool that opened the page. + // + // A chat flow has no run form to fill: its page is a conversation, so the proposed + // `user_message` goes into the composer for the reader to edit and send. The composer + // declines if they were already typing, which leaves their draft alone. + let flowChat: FlowChat | undefined = $state() + let seededSeq: number | undefined = undefined + $effect(() => { + if (!seededRun) return + const surface = chatInputEnabled ? flowChat : runForm + if (!surface) return + if (seededSeq === seededRun.seq) return + seededSeq = seededRun.seq + if (chatInputEnabled) { + const message = seededRun.args?.user_message + if (typeof message === 'string' && message) flowChat?.offerMessage(message) + } else { + runForm?.setArgs(seededRun.args) + } + }) + // The dev workspace's editor is not one the session panel can host, so from a preview tab // it opens in a new browser tab, as the session editors' own entry does. function editInFork(e: Event | undefined, itemPath: string) { @@ -812,6 +845,7 @@ {#if chatInputEnabled} {/if} + {#if seededRun} + onClearSeededRun?.()} /> + {/if} + ; seq: number } + /** The reader dismissed that note. The values stay in the fields. */ + onClearSeededRun?: () => void /** How the load ended, for a host that renders its own state around this page. * Providing it also suppresses the "could not load" toast: a 404 here is the normal * state of a not-yet-deployed path, which the host explains in place instead. */ @@ -413,6 +422,18 @@ let args: Record | undefined = $state(undefined) + // Seeded once the form exists, and again whenever another request arrives for a page + // already open — latched on the request rather than on "seeded once", which would leave + // the previous turn's values on screen. The arguments were narrowed against this script's + // deployed schema before they got here, by the tool that opened the page. + let seededSeq: number | undefined = undefined + $effect(() => { + if (!seededRun || !runForm) return + if (seededSeq === seededRun.seq) return + seededSeq = seededRun.seq + runForm.setArgs(seededRun.args) + }) + // Read once on purpose: these args seed the form, so tracking the fragment would // overwrite what the user has typed whenever it changes. let hash = untrack(() => locationHash) @@ -1084,6 +1105,9 @@ /> {/if} + {#if seededRun} + onClearSeededRun?.()} /> + {/if} { const props = inputSchema?.properties ?? {} diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index d8dee9dcaa..9b28019796 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -579,6 +579,15 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender

- Using {inputSelected === 'history' - ? 'historic' - : inputSelected === 'captures' - ? 'captures' - : inputSelected === 'ai' - ? 'AI generated' - : 'saved'} input arguments + {#if inputSelected === 'agent'} + The agent filled these arguments + {:else} + Using {inputSelected === 'history' + ? 'historic' + : inputSelected === 'captures' + ? 'captures' + : inputSelected === 'ai' + ? 'AI generated' + : 'saved'} input arguments + {/if}

{#if acceptButton} {@render acceptButton()} diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte index b805f1b91a..58deabe52b 100644 --- a/frontend/src/lib/components/sessions/ItemViewerView.svelte +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -66,6 +66,11 @@ reloadKey++ }) + // Arguments a chat tool filled this item's run form with when it opened the page. Nothing + // is waiting on them: the reader runs the page as they would with no chat open, and the + // note above the form only says where the values came from. + const seededRun = $derived(runtime.seededRunArgsFor(kind, path)) + /** Refetch the deployed item. The host calls this for the reload signals that reach a * tab from outside it; nothing about becoming visible triggers it, so a form the reader * filled in survives switching tabs away and back. */ @@ -179,50 +184,54 @@ - {#key loadKey} - {@const mountedKey = loadKey} - {@const setLoadState = (state: 'loaded' | 'not_found') => onLoadState(mountedKey, state)} - {#if notDeployed} - -
-
Not deployed yet
-
- Nothing is deployed at {path}, so there is no deployed - version to view. Deploy it from the editor to see it here. -
- +
+
Not deployed yet
+
+ Nothing is deployed at {path}, so there is no deployed + version to view. Deploy it from the editor to see it here.
- {:else if kind === 'script'} - - {:else if kind === 'flow'} - - {:else} - onNavigate(`/apps_raw/edit/${path}`)} - onLoadState={setLoadState} - syncHashToUrl={false} - /> - {/if} - {/key} + +
+ {:else if kind === 'script'} + runtime.clearSeededRunArgs(kind, path)} + onLoadState={setLoadState} + /> + {:else if kind === 'flow'} + runtime.clearSeededRunArgs(kind, path)} + onLoadState={setLoadState} + /> + {:else} + onNavigate(`/apps_raw/edit/${path}`)} + onLoadState={setLoadState} + syncHashToUrl={false} + /> + {/if} +{/key} diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 77d0a5c200..a0f792c24b 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -223,6 +223,13 @@ export interface SessionRuntime { * viewer refetches when this changes — it reads the deployed version from the * API, so nothing else tells it the editor beside it just published. */ deployedRevision(kind: SessionTargetKind, path: string): number + /** What a chat tool filled this item's deployed run form with, if one opened the page. */ + seededRunArgsFor( + kind: SessionTargetKind, + path: string + ): { args: Record; seq: number } | undefined + /** Drop that note; the arguments themselves stay in the form. */ + clearSeededRunArgs(kind: SessionTargetKind, path: string): void /** Record that a preview tab landed on `url`. Fires for a newly opened tab and * for an existing one switched to the other side of its item. */ logTabUsage(url: string): void @@ -564,6 +571,30 @@ function createRuntime(session: Session): SessionRuntime { onTabOpened: logTabUsage }) + // Arguments a chat tool filled a deployed page's run form with, keyed by the item it + // showed. Not persisted: they belong to the turn that proposed them, and a restored tab + // comes back as a plain deployed page. + const seededRunArgs = new SvelteMap; seq: number }>() + const seededKey = (kind: SessionTargetKind, path: string) => `${kind}:${path}` + let seededRunSeq = 0 + + /** What a tool filled this item's run form with, for the viewer that renders it. `seq` + * identifies the request: a page already open is re-seeded rather than reopened, so a + * latch on "seeded once" would leave the previous turn's arguments on screen. */ + function seededRunArgsFor( + kind: SessionTargetKind, + path: string + ): { args: Record; seq: number } | undefined { + return seededRunArgs.get(seededKey(kind, path)) + } + + /** The reader dismissed the note above the form. The arguments stay in the fields — there + * is nothing to restore them to — so this only stops the page claiming they came from the + * chat. */ + function clearSeededRunArgs(kind: SessionTargetKind, path: string): void { + seededRunArgs.delete(seededKey(kind, path)) + } + // Let the jobs tray open a run in this session's preview panel (as an iframe // tab over the run page). The global side-panel chat leaves this unset and // falls back to a new browser tab. @@ -581,6 +612,20 @@ function createRuntime(session: Session): SessionRuntime { manager.openRunForm = ({ toolCallId, label }) => { previewTabs.open({ type: 'runform', toolCallId, label }) } + // A DEPLOYED run opens the item's own page instead, with the model's arguments filled + // 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 }) => { + // 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. + const target = previewTargetForSessionTarget(kind, path) + if (target?.type !== 'item') return false + seededRunArgs.set(seededKey(kind, path), { args, seq: ++seededRunSeq }) + previewTabs.open({ ...target, item: { ...target.item, summary }, mode: 'view' }) + return true + } manager.closeRunForm = (toolCallId) => previewTabs.closeRunForm(toolCallId) manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => { previewTabs.retargetRunForm(toolCallId, `${base}/run/${jobId}?workspace=${workspace}`) @@ -626,6 +671,8 @@ function createRuntime(session: Session): SessionRuntime { flowCell, loadedEditorPath, deployedRevision, + seededRunArgsFor, + clearSeededRunArgs, logTabUsage, async loadFlow(workspace: string, path: string, force = false) {