mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
feat(sessions): run a deployed item from its own page, for the chat
The run card stays in the chat by default. Preview opens the item's deployed page in the preview panel with the proposed arguments already filled, and Run there hands them back to the parked tool call — so the chat still owns the job and reports on its result. A chat flow has no run form to fill: its page is a conversation, so the proposed message goes into the composer and a strip above it says what was asked for. Sending it runs the flow in that conversation, which settles the call with a reason rather than a refusal — the card says "Handed off", not "Cancelled". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8aa84bc624
commit
72ea7d416a
@@ -570,11 +570,12 @@ 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
|
||||
/** Set by a host that can show a deployed page — a session's preview panel. Nothing calls
|
||||
* it directly: the reader's own way in is `openDeployedRunPage`, and a run tool's is the
|
||||
* private `#handOverDeployedRun`, which refuses after a stop. The turn is the manager's to
|
||||
* know about, not the host's. */
|
||||
openDeployedRunPageHandler?: (a: {
|
||||
/** A DEPLOYED run's form can be confirmed on the item's own page instead of on the card:
|
||||
* the page takes over this call, so its Run hands the arguments back here and the tool
|
||||
* still owns the run. Set only where that page can be shown — a session's preview panel —
|
||||
* so the card hides the control elsewhere. */
|
||||
openRunOnDeployedPage?: (a: {
|
||||
toolCallId: string
|
||||
kind: 'script' | 'flow'
|
||||
path: string
|
||||
summary: string
|
||||
@@ -2097,38 +2098,6 @@ export class AIChatManager implements ChatViewHost {
|
||||
return false
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
* This is the reader's own way in — the card's eye, reopening a page they closed — so it
|
||||
* carries no turn check. False means there is no session to show it in. The tool's way in
|
||||
* is {@link #handOverDeployedRun}, which additionally refuses after a stop. */
|
||||
openDeployedRunPage = (a: {
|
||||
kind: 'script' | 'flow'
|
||||
path: string
|
||||
summary: string
|
||||
args: Record<string, any>
|
||||
}): boolean => this.openDeployedRunPageHandler?.(a) ?? false
|
||||
|
||||
/** What the run tools call. Opening after a stop would act on a turn the reader ended, and
|
||||
* would write a form onto a card the stop already settled — which the card then reads as
|
||||
* `Opened`, hiding the cancellation. Declining hands the call on to `requestRunArgs`,
|
||||
* whose own stop guard settles it as cancelled.
|
||||
*
|
||||
* The controller is per-turn and stays aborted once stopped, so this check belongs here
|
||||
* rather than on `openDeployedRunPage`: the reader's eye button must keep working on a
|
||||
* card whose turn was stopped long ago. */
|
||||
#handOverDeployedRun = (a: {
|
||||
kind: 'script' | 'flow'
|
||||
path: string
|
||||
summary: string
|
||||
args: Record<string, any>
|
||||
}): boolean => {
|
||||
if (this.abortController?.signal.aborted) return false
|
||||
return this.openDeployedRunPage(a)
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
handleRunFormSubmit = (toolId: string, args: Record<string, any>): boolean => {
|
||||
@@ -2137,7 +2106,24 @@ export class AIChatManager implements ChatViewHost {
|
||||
return true
|
||||
}
|
||||
|
||||
handleRunFormCancel = (toolId: string) => {
|
||||
/** Set by the settler, read once by the waiting tool. Not on the display message: that is
|
||||
* what the reader sees, and this is what the model is told. */
|
||||
#runFormDeclineReasons = new Map<string, string>()
|
||||
|
||||
runFormDeclineReason = (toolId: string): string | undefined => {
|
||||
const reason = this.#runFormDeclineReasons.get(toolId)
|
||||
this.#runFormDeclineReasons.delete(toolId)
|
||||
return reason
|
||||
}
|
||||
|
||||
/**
|
||||
* `reason` replaces what the model is told, for the ways out that are not a plain refusal:
|
||||
* a chat flow's page has no Run button to confirm on, so sending the message there starts
|
||||
* the run and settles this call — telling the model it was "cancelled" would have it
|
||||
* report that nothing ran.
|
||||
*/
|
||||
handleRunFormCancel = (toolId: string, reason?: string) => {
|
||||
if (reason) this.#runFormDeclineReasons.set(toolId, reason)
|
||||
// The card's own copy is settled here rather than only in the tool's fn, which a form
|
||||
// restored from history no longer has: Cancel is that card's one way out, and while it
|
||||
// stays active the whole session reads as needs-confirmation (getSessionChatStatus asks
|
||||
@@ -2145,8 +2131,8 @@ export class AIChatManager implements ChatViewHost {
|
||||
// alone unmounts the form but leaves the card shimmering.
|
||||
this.#settleRunForm(toolId, undefined, (runForm) => ({
|
||||
isLoading: false,
|
||||
error: 'Cancelled by user',
|
||||
content: `Run of "${runForm.path}" cancelled by user`
|
||||
error: reason ? 'Handled by user' : 'Cancelled by user',
|
||||
content: reason ?? `Run of "${runForm.path}" cancelled by user`
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4191,7 +4177,7 @@ export class AIChatManager implements ChatViewHost {
|
||||
requestUserQuestion: this.requestUserQuestion,
|
||||
requestRunArgs: this.requestRunArgs,
|
||||
markRunFormStarted: this.markRunFormStarted,
|
||||
openDeployedRunPage: this.#handOverDeployedRun,
|
||||
runFormDeclineReason: this.runFormDeclineReason,
|
||||
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),
|
||||
|
||||
@@ -92,24 +92,12 @@
|
||||
// "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 &&
|
||||
(openedDeployedPage ||
|
||||
message.result !== undefined ||
|
||||
(message.result !== undefined ||
|
||||
failed ||
|
||||
canceled ||
|
||||
(chatJob !== undefined && ['success', 'failure', 'canceled'].includes(chatJob.status)))
|
||||
@@ -182,33 +170,34 @@
|
||||
// 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'
|
||||
: openedDeployedPage
|
||||
? 'Opened'
|
||||
: running
|
||||
? verbs.present
|
||||
: settled && ran
|
||||
? verbs.past
|
||||
: verbs.future
|
||||
inspected ? 'Inspected' : 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
|
||||
// loud: nothing came back, so no other tab can carry it.
|
||||
const outcomeTab = $derived(
|
||||
failed ? 'Error' : canceled ? (ran ? 'Cancelled' : 'Not run') : 'Result'
|
||||
failed
|
||||
? 'Error'
|
||||
: message.ranElsewhere
|
||||
? 'Handed off'
|
||||
: canceled
|
||||
? ran
|
||||
? 'Cancelled'
|
||||
: 'Not run'
|
||||
: 'Result'
|
||||
)
|
||||
// What the tool call ended as, which for a handover is not a cancellation: the job exists,
|
||||
// it is just not this call's to report — the page the reader ran it from has it.
|
||||
const cancelReason = $derived(
|
||||
ran
|
||||
? `This run was cancelled while the ${runnableKind} was running.`
|
||||
: `This run was cancelled before the ${runnableKind} started.`
|
||||
message.ranElsewhere
|
||||
? `This ${runnableKind} was run from its own page, so this call started no job.`
|
||||
: ran
|
||||
? `This run was cancelled while the ${runnableKind} was running.`
|
||||
: `This run was cancelled before the ${runnableKind} started.`
|
||||
)
|
||||
// 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.
|
||||
// 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 hasOutcome = $derived(settled || streaming)
|
||||
const tabs = $derived([
|
||||
{ value: 'input', label: 'Inputs' },
|
||||
...(ran ? [{ value: 'logs', label: 'Logs' }] : []),
|
||||
@@ -392,31 +381,33 @@
|
||||
// How long it took, which is the one thing the colour cannot say. A run that never started
|
||||
// has no time to give, so its outcome takes the slot — as a word, never "Not run", which
|
||||
// stutters against the "Run <name>" label beside it.
|
||||
const outcome = $derived(failed ? 'Failed' : canceled ? 'Cancelled' : 'Done')
|
||||
// A call that only opened the page has no outcome and no duration, and "Done" there would
|
||||
// claim the completion the rest of this card is careful not to imply. The label already
|
||||
// says `Opened`, which is the whole status.
|
||||
const statusTime = $derived(openedDeployedPage ? '' : running ? elapsed : duration || outcome)
|
||||
// A call the reader settled by running the item on its own page is a decline to this tool
|
||||
// and a run to them: "Cancelled" would say the opposite of what they just watched happen.
|
||||
const outcome = $derived(
|
||||
failed ? 'Failed' : message.ranElsewhere ? 'Handed off' : canceled ? 'Cancelled' : 'Done'
|
||||
)
|
||||
const statusTime = $derived(running ? elapsed : duration || outcome)
|
||||
|
||||
// What the preview button opens changes with the card: the form while the call is still
|
||||
// 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.
|
||||
// A deployed run can be confirmed on the item's own page instead of on this card: the page
|
||||
// takes over the call, so Run there is Run here. A test run has no deployed page — it
|
||||
// executes the draft being written — so it keeps the chat's own form tab.
|
||||
const asDeployedPage = $derived(
|
||||
openedDeployedPage && !!path && !!aiChatManager.openDeployedRunPage
|
||||
pending && runForm?.kind === 'run' && !!path && !!aiChatManager.openRunOnDeployedPage
|
||||
)
|
||||
const previewTarget = $derived(
|
||||
pending
|
||||
? aiChatManager.openRunForm
|
||||
? ('form' as const)
|
||||
: undefined
|
||||
? asDeployedPage
|
||||
? ('deployed' as const)
|
||||
: aiChatManager.openRunForm
|
||||
? ('form' as const)
|
||||
: undefined
|
||||
: job
|
||||
? ('run' as const)
|
||||
: asDeployedPage
|
||||
? ('deployed' as const)
|
||||
: undefined
|
||||
: undefined
|
||||
)
|
||||
const previewTitle = $derived(
|
||||
previewTarget === 'form'
|
||||
@@ -430,16 +421,21 @@
|
||||
|
||||
function openPreview() {
|
||||
const label = runnableName
|
||||
// The panel now shows what this card was showing, so the card folds away rather than
|
||||
// holding a second copy open in the transcript. Through `toggled`, so it reads as an
|
||||
// ordinary collapse the reader can undo.
|
||||
if (previewTarget === 'deployed') toggled = { id: message.tool_call_id, open: false }
|
||||
if (previewTarget === 'form') {
|
||||
aiChatManager.openRunForm?.({ toolCallId: message.tool_call_id, label })
|
||||
return
|
||||
}
|
||||
if (previewTarget === 'deployed') {
|
||||
aiChatManager.openDeployedRunPage?.({
|
||||
aiChatManager.openRunOnDeployedPage?.({
|
||||
toolCallId: message.tool_call_id,
|
||||
kind: runnableKind === 'flow' ? 'flow' : 'script',
|
||||
path,
|
||||
summary: label,
|
||||
args: runForm?.args ?? {}
|
||||
args: aiChatManager.runFormDraft(message.tool_call_id, runForm!).args ?? {}
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -685,7 +681,9 @@
|
||||
<p class="text-2xs font-medium leading-4 text-secondary">{cancelReason}</p>
|
||||
{#if !ran}
|
||||
<p class="text-2xs leading-4 text-tertiary">
|
||||
The inputs it would have run with are on the Inputs tab.
|
||||
{message.ranElsewhere
|
||||
? 'The inputs it was opened with are on the Inputs tab.'
|
||||
: 'The inputs it would have run with are on the Inputs tab.'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -5126,75 +5126,6 @@ describe('global AI tools', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A DEPLOYED run inside a session is handed to the item's own page and the call ends
|
||||
// there: the user runs it from the page, with the scheduling, tag and version controls
|
||||
// this tool could never carry. The three things that must hold are that no job is
|
||||
// started, that the model is told so rather than left to infer a run, and that a TEST
|
||||
// run — which executes the draft, not what the page shows — never takes the branch.
|
||||
it('hands a deployed run to the item’s page and starts no job', async () => {
|
||||
const script = {
|
||||
path: 'f/scripts/handed-over',
|
||||
summary: 'Handed over',
|
||||
content: 'export async function main(name: string) {}',
|
||||
language: 'bun',
|
||||
schema: { properties: { name: { type: 'string' } } }
|
||||
} as any
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce(script)
|
||||
|
||||
const opened: any[] = []
|
||||
const requestRunArgs = vi.fn(async (_toolId: string, form: any) => form.args)
|
||||
const result = await callGlobalTool(
|
||||
'run_script',
|
||||
{ path: 'f/scripts/handed-over', args: { name: 'Ada' } },
|
||||
{
|
||||
...toolCallbacks,
|
||||
requestRunArgs,
|
||||
openDeployedRunPage: (a: any) => {
|
||||
opened.push(a)
|
||||
return true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(opened).toEqual([
|
||||
{
|
||||
kind: 'script',
|
||||
path: 'f/scripts/handed-over',
|
||||
summary: 'Handed over',
|
||||
args: { name: 'Ada' }
|
||||
}
|
||||
])
|
||||
// Never parks on a form: there is nothing to confirm when the page is the confirmation.
|
||||
expect(requestRunArgs).not.toHaveBeenCalled()
|
||||
expect(JobService.runScriptByPath).not.toHaveBeenCalled()
|
||||
// The model reports back from this string, so it has to deny the run outright — an
|
||||
// "opened" it read as "started" would have it tell the user the job is away.
|
||||
expect(result).toContain('has NOT run')
|
||||
})
|
||||
|
||||
// The draft is what a test executes, and the deployed page does not show it. Handing one
|
||||
// over would run the deployed version instead — a different script.
|
||||
it('keeps a test run on its own form even where a page could be opened', async () => {
|
||||
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
|
||||
path: 'f/scripts/still-tested',
|
||||
content: 'export async function main(name: string) {}',
|
||||
language: 'bun',
|
||||
schema: { properties: { name: { type: 'string' } } }
|
||||
} as any)
|
||||
|
||||
const openDeployedRunPage = vi.fn(() => true)
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'test_run_script',
|
||||
{ path: 'f/scripts/still-tested', args: { name: 'Ada' } },
|
||||
{ ...toolCallbacks, openDeployedRunPage }
|
||||
)
|
||||
)
|
||||
|
||||
expect(openDeployedRunPage).not.toHaveBeenCalled()
|
||||
expect(JobService.runScriptPreview).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The bypass posture answers a run form as it answers any other confirmation, for a
|
||||
// deployed run as much as a test. The card must never render one first: a form nobody
|
||||
// will fill in is attached already settled, so no field is ever mounted, and the schema
|
||||
@@ -5211,16 +5142,11 @@ describe('global AI tools', () => {
|
||||
|
||||
const statuses: any[] = []
|
||||
const requestRunArgs = vi.fn(async (_toolId: string, form: any) => form.args)
|
||||
// Wired on purpose: the posture answers for a reader who already consented, so a
|
||||
// deployed run must still run here rather than being handed to a page with nobody
|
||||
// left to press Run.
|
||||
const openDeployedRunPage = vi.fn(() => true)
|
||||
const yolo = {
|
||||
...toolCallbacks,
|
||||
setToolStatus: (_toolId: string, status: any) => statuses.push(status),
|
||||
shouldAutoAcceptToolConfirmations: () => true,
|
||||
requestRunArgs,
|
||||
openDeployedRunPage
|
||||
requestRunArgs
|
||||
}
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
@@ -5250,7 +5176,6 @@ describe('global AI tools', () => {
|
||||
expect(JobService.runScriptByPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestBody: { name: 'Ada' }, skipPreprocessor: true })
|
||||
)
|
||||
expect(openDeployedRunPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The posture is the user's standing answer to whether to ask, so a run it answers starts on
|
||||
|
||||
@@ -907,14 +907,14 @@ const backgroundArgSchema = z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow. Has no effect where the call hands a deployed run to the item's own page: nothing is waited on there, because the user starts the job themselves."
|
||||
'Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.'
|
||||
)
|
||||
|
||||
const waitSecondsArgSchema = z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true, and where the call hands a deployed run to the item's own page — there is no job to wait for until the user starts one. Do not use this to poll — larger values just hold the turn longer."
|
||||
'How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.'
|
||||
)
|
||||
|
||||
/** Translate the model's `wait_seconds` into executeTestRun's `detachAfterMs`
|
||||
@@ -948,7 +948,7 @@ const runScriptSchema = z.object({
|
||||
const runScriptToolDef = createToolDef(
|
||||
runScriptSchema,
|
||||
'run_script',
|
||||
'Run a DEPLOYED script for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. Inside an AI session this opens the script\'s own deployed page with that form filled in and the call ENDS THERE — the user runs it from the page, so do not report it as started, and read the call\'s result for which happened. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").',
|
||||
'Run a DEPLOYED script for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the script\'s own deployed page instead; either way this call waits for them and runs what they confirm. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").',
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
@@ -987,7 +987,7 @@ const runFlowSchema = z.object({
|
||||
const runFlowToolDef = createToolDef(
|
||||
runFlowSchema,
|
||||
'run_flow',
|
||||
'Run a DEPLOYED flow for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. Inside an AI session this opens the flow\'s own deployed page with that form filled in and the call ENDS THERE — the user runs it from the page, so do not report it as started, and read the call\'s result for which happened. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").',
|
||||
'Run a DEPLOYED flow for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the flow\'s own deployed page instead; either way this call waits for them and runs what they confirm. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").',
|
||||
{ strict: false }
|
||||
)
|
||||
|
||||
@@ -3919,7 +3919,7 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
confirmationMessage: 'Run a deployed script',
|
||||
// Both endings start the same way: the arguments are read, then either a form opens
|
||||
// here or the script's own deployed page does.
|
||||
streamingLabel: 'Preparing the arguments...',
|
||||
streamingLabel: 'Preparing the run form...',
|
||||
queuedLabel: (args) => `Run ${args?.path ?? 'a script'}`,
|
||||
showDetails: true,
|
||||
autoCollapseDetails: false
|
||||
@@ -3947,7 +3947,7 @@ export const globalTools: SessionTool<{}>[] = [
|
||||
return runDeployedFlow(parsed, ctx)
|
||||
},
|
||||
bypassedByAutoAccept: true,
|
||||
streamingLabel: 'Preparing the arguments...',
|
||||
streamingLabel: 'Preparing the run form...',
|
||||
confirmationMessage: 'Run a deployed flow',
|
||||
queuedLabel: (args) => `Run ${args?.path ?? 'a flow'}`,
|
||||
showDetails: true,
|
||||
@@ -6040,13 +6040,6 @@ async function testRunScriptByPath(
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 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 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
|
||||
* way out. */
|
||||
@@ -6188,41 +6181,6 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st
|
||||
// size guard that covers `parameters`.
|
||||
const persisted = { ...form, args: redactFileArgs(proposed, schema as any) }
|
||||
|
||||
// A DEPLOYED run is handed to the item's own page and this call ends there. The reader
|
||||
// runs it from the page they would have run it from anyway — with its scheduling, tag
|
||||
// override and pinned version in reach, none of which this tool could carry — so there is
|
||||
// no form to park on and no job for the tool to own.
|
||||
//
|
||||
// A TEST run has no such page: it executes the draft being written, which the deployed
|
||||
// page does not show, so it keeps the form below. So does the bypass posture, which
|
||||
// answers for a reader who already consented and would leave nobody to press Run.
|
||||
if (spec.kind === 'run' && !autoAccepted && toolCallbacks.openDeployedRunPage) {
|
||||
const handedOver = toolCallbacks.openDeployedRunPage({
|
||||
kind: spec.contextName === 'flow' ? 'flow' : 'script',
|
||||
path: spec.path,
|
||||
summary: spec.summary ?? spec.path,
|
||||
args: proposed
|
||||
})
|
||||
if (handedOver) {
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Opened the deployed page for "${spec.path}"`,
|
||||
// Carried without `isLoading`, so `isActiveRunForm` reads it as settled: the card
|
||||
// shows what was proposed and offers to reopen the page, rather than mounting a
|
||||
// second form for a call that is already over. Without the schema for the same
|
||||
// reason a submitted or cancelled form drops it — no form will mount from this,
|
||||
// so keeping it would persist the script's declarations, password and file
|
||||
// defaults included, for the life of the chat.
|
||||
runForm: { ...persisted, schema: undefined, code: undefined, lang: undefined },
|
||||
// The card settles on what the page opened with, as it would on what a form
|
||||
// opened with: it is the record of what this call proposed.
|
||||
parameters: persisted.args,
|
||||
isLoading: false,
|
||||
isStreamingArguments: false
|
||||
})
|
||||
return deployedPageOpened(spec.path, noun, spec.toolName)
|
||||
}
|
||||
}
|
||||
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: autoAccepted
|
||||
? spec.startMessage
|
||||
@@ -6239,14 +6197,20 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st
|
||||
? await toolCallbacks.requestRunArgs(toolId, form, { autoAccepted })
|
||||
: proposed
|
||||
if (!submitted) {
|
||||
// A page that settled the call by running the item its own way says so; without one
|
||||
// this was a refusal.
|
||||
const handled = toolCallbacks.runFormDeclineReason?.(toolId)
|
||||
toolCallbacks.setToolStatus(toolId, {
|
||||
content: `Run of "${spec.path}" cancelled by user`,
|
||||
content: handled ?? `Run of "${spec.path}" cancelled by user`,
|
||||
isLoading: false,
|
||||
isStreamingArguments: false,
|
||||
error: 'Cancelled by user',
|
||||
declinedByUser: true
|
||||
error: handled ? 'Handled by user' : 'Cancelled by user',
|
||||
declinedByUser: true,
|
||||
ranElsewhere: !!handled
|
||||
})
|
||||
return runFormCancelled(spec.toolName, noun)
|
||||
return handled
|
||||
? `${handled} Do not call ${spec.toolName} again unless the user asks for it.`
|
||||
: runFormCancelled(spec.toolName, noun)
|
||||
}
|
||||
|
||||
const blockedBeforeRun = blockedByPlanMode()
|
||||
|
||||
@@ -683,6 +683,10 @@ export type ToolDisplayMessage = {
|
||||
/** The user declined: the reject button, a Stop, or a posture switch. Set only there, so
|
||||
* a decision is distinguishable from every other way a call errors. */
|
||||
declinedByUser?: boolean
|
||||
/** The user settled the call by running the item themselves, elsewhere. Still a decline —
|
||||
* this tool started nothing — but the run happened, so the card must not call it
|
||||
* cancelled. */
|
||||
ranElsewhere?: boolean
|
||||
}
|
||||
|
||||
export type AssistantDisplayMessage = BaseDisplayMessage & {
|
||||
@@ -1418,21 +1422,12 @@ export interface ToolCallbacks {
|
||||
) => Promise<Record<string, any> | 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. 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
|
||||
summary: string
|
||||
args: Record<string, any>
|
||||
}) => boolean
|
||||
/** Why a parked form settled without submitting, when it was not a refusal: a chat flow's
|
||||
* deployed page has no Run button, so sending the proposed message in its own composer is
|
||||
* what settles the call — and telling the model it was cancelled would have it report that
|
||||
* nothing ran. Read once, right after `requestRunArgs` resolves undefined; nothing here
|
||||
* means the user cancelled. */
|
||||
runFormDeclineReason?: (toolId: string) => string | undefined
|
||||
/** 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). */
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import { Badge as HeaderBadge, Alert } from '$lib/components/common'
|
||||
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import RunForm from '$lib/components/RunForm.svelte'
|
||||
import type { PendingRun } from '$lib/components/details/pendingRun'
|
||||
import { processSecretArgs } from '$lib/components/secretArgUtils'
|
||||
import type { Schema } from '$lib/common'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
@@ -103,10 +104,7 @@
|
||||
onNavigate = goto,
|
||||
active = true,
|
||||
embedded = false,
|
||||
seededRun,
|
||||
seededByAgent = false,
|
||||
onSeedApplied,
|
||||
onClearSeededRun,
|
||||
pendingRun,
|
||||
onLoadState
|
||||
}: {
|
||||
/** The `[...path]` route segment: the flow's path. */
|
||||
@@ -122,18 +120,10 @@
|
||||
/** 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<string, any>; seq: number }
|
||||
/** Whether to say the agent filled them. Outlives `seededRun`, since the note stands
|
||||
* until the reader dismisses it. */
|
||||
seededByAgent?: boolean
|
||||
/** Applied, so the host stops offering them. */
|
||||
onSeedApplied?: () => void
|
||||
/** The reader dismissed the note. The values stay in the fields. */
|
||||
onClearSeededRun?: () => void
|
||||
/** A chat tool call waiting on this form, when the reader chose to confirm it here
|
||||
* rather than on the card. Seeds the arguments it proposed and takes over Run; see
|
||||
* {@link PendingRun} for why the page must not run it itself. */
|
||||
pendingRun?: PendingRun
|
||||
/** How the load ended, for a host that renders its own state around this page. */
|
||||
onLoadState?: (state: 'loaded' | 'not_found') => void
|
||||
} = $props()
|
||||
@@ -349,6 +339,12 @@
|
||||
requestBody,
|
||||
skipPreprocessor: true
|
||||
})
|
||||
// The reader ran the flow themselves, which is what the parked call was asking for.
|
||||
// Settle it rather than leaving it waiting on a Run button this page never shows;
|
||||
// submitting instead would have the tool start a second job for the same request.
|
||||
pendingRun?.decline(
|
||||
`The user ran "${path}" themselves by sending the message in the flow's own chat, so this call did not start a job. The run is in that conversation.`
|
||||
)
|
||||
return run
|
||||
}
|
||||
|
||||
@@ -380,40 +376,46 @@
|
||||
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.
|
||||
// Run hands the arguments to the waiting call instead of starting a job: the tool that
|
||||
// parked on this form starts one itself when it resumes.
|
||||
const runAction = $derived(
|
||||
pendingRun
|
||||
? (_scheduledForStr: string | undefined, a: Record<string, any>) => {
|
||||
if (!pendingRun.submit(a)) {
|
||||
sendUserToast('That request is no longer waiting on this form', true)
|
||||
}
|
||||
}
|
||||
: runFlow
|
||||
)
|
||||
|
||||
// Seeded once per call rather than once per mount: a tab already showing this item is
|
||||
// reused for the next request, so a latch on "seeded" would leave the previous call's
|
||||
// arguments on screen.
|
||||
//
|
||||
// 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.
|
||||
// `user_message` goes into the composer for the reader to edit and send, and sending it
|
||||
// settles the call — `runFlowForChat` says so. The composer mounts a flush or two after
|
||||
// the panel, so the effect waits on `composerReady` rather than latching into a void.
|
||||
let flowChat: FlowChat | undefined = $state()
|
||||
let seededSeq: number | undefined = undefined
|
||||
let seededCallId: string | undefined = undefined
|
||||
$effect(() => {
|
||||
if (!seededRun || seededSeq === seededRun.seq) return
|
||||
if (!pendingRun || seededCallId === pendingRun.toolCallId) return
|
||||
if (chatInputEnabled) {
|
||||
// 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
|
||||
seededSeq = seededRun.seq
|
||||
seededCallId = pendingRun.toolCallId
|
||||
// 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 ?? {}
|
||||
const { user_message: message, ...inputs } = pendingRun.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.
|
||||
// would put words in the box long after they were proposed. The strip above it
|
||||
// still says what was asked for.
|
||||
if (typeof message === 'string' && message) flowChat.offerMessage(message)
|
||||
onSeedApplied?.()
|
||||
} else if (runForm) {
|
||||
seededSeq = seededRun.seq
|
||||
runForm.setArgs(seededRun.args)
|
||||
onSeedApplied?.()
|
||||
seededCallId = pendingRun.toolCallId
|
||||
runForm.setArgs(pendingRun.args)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -886,7 +888,16 @@
|
||||
flowModules={flow?.value?.modules}
|
||||
wideLayout
|
||||
frame="none"
|
||||
/>
|
||||
>
|
||||
{#snippet inputPreface()}
|
||||
{#if pendingRun}
|
||||
<InputSelectedBadge
|
||||
inputSelected="pending_run"
|
||||
onReject={() => pendingRun.decline()}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FlowChat>
|
||||
{:else}
|
||||
{@const hasSchema =
|
||||
flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0}
|
||||
@@ -929,8 +940,11 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if seededByAgent}
|
||||
<InputSelectedBadge inputSelected="agent" onReject={() => onClearSeededRun?.()} />
|
||||
{#if pendingRun}
|
||||
<InputSelectedBadge
|
||||
inputSelected="pending_run"
|
||||
onReject={() => pendingRun.decline()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<RunForm
|
||||
@@ -945,8 +959,9 @@
|
||||
detailed={false}
|
||||
bind:isValid
|
||||
runnable={flow}
|
||||
runAction={runFlow}
|
||||
{runAction}
|
||||
bind:args
|
||||
schedulable={!pendingRun}
|
||||
bind:this={runForm}
|
||||
{jsonView}
|
||||
/>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
} from '$lib/components/common'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import RunForm from '$lib/components/RunForm.svelte'
|
||||
import type { PendingRun } from '$lib/components/details/pendingRun'
|
||||
import DbtRunGraph from '$lib/components/dbt/DbtRunGraph.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
@@ -126,10 +127,7 @@
|
||||
onNavigate = goto,
|
||||
active = true,
|
||||
embedded = false,
|
||||
seededRun,
|
||||
seededByAgent = false,
|
||||
onSeedApplied,
|
||||
onClearSeededRun,
|
||||
pendingRun,
|
||||
onLoadState
|
||||
}: {
|
||||
/** The `[...hash]` route segment: either a script hash or a script path. */
|
||||
@@ -145,20 +143,10 @@
|
||||
/** Rendered inside a page that is not the script'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 proposed when it opened the page, for as long as they have
|
||||
* still to be applied. Nothing waits on them: the page runs as it always does, and
|
||||
* these only fill the fields. `seq` identifies the request, so a second one re-seeds a
|
||||
* page already open. Gone once applied — this page remounts under its host on a
|
||||
* deploy, a refresh or a version pin, and applying again would overwrite what the
|
||||
* reader has typed since. */
|
||||
seededRun?: { args: Record<string, any>; seq: number }
|
||||
/** Whether to say the agent filled them. Outlives `seededRun`, since the note stands
|
||||
* until the reader dismisses it. */
|
||||
seededByAgent?: boolean
|
||||
/** Applied, so the host stops offering them. */
|
||||
onSeedApplied?: () => void
|
||||
/** The reader dismissed the note. The values stay in the fields. */
|
||||
onClearSeededRun?: () => void
|
||||
/** A chat tool call waiting on this form, when the reader chose to confirm it here
|
||||
* rather than on the card. Seeds the arguments it proposed and takes over Run; see
|
||||
* {@link PendingRun} for why the page must not run it itself. */
|
||||
pendingRun?: PendingRun
|
||||
/** 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. */
|
||||
@@ -435,13 +423,27 @@
|
||||
// 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
|
||||
// Run hands the arguments to the waiting call instead of starting a job: the tool that
|
||||
// parked on this form starts one itself when it resumes.
|
||||
const runAction = $derived(
|
||||
pendingRun
|
||||
? (_scheduledForStr: string | undefined, a: Record<string, any>) => {
|
||||
if (!pendingRun.submit(a)) {
|
||||
sendUserToast('That request is no longer waiting on this form', true)
|
||||
}
|
||||
}
|
||||
: runScript
|
||||
)
|
||||
|
||||
// Seeded once per call rather than once per mount: a tab already showing this item is
|
||||
// reused for the next request, so a latch on "seeded" would leave the previous call's
|
||||
// arguments on screen.
|
||||
let seededCallId: string | undefined = undefined
|
||||
$effect(() => {
|
||||
if (!seededRun || !runForm) return
|
||||
if (seededSeq === seededRun.seq) return
|
||||
seededSeq = seededRun.seq
|
||||
runForm.setArgs(seededRun.args)
|
||||
onSeedApplied?.()
|
||||
if (!pendingRun || !runForm) return
|
||||
if (seededCallId === pendingRun.toolCallId) return
|
||||
seededCallId = pendingRun.toolCallId
|
||||
runForm.setArgs(pendingRun.args)
|
||||
})
|
||||
|
||||
// Read once on purpose: these args seed the form, so tracking the fragment would
|
||||
@@ -1115,8 +1117,11 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if seededByAgent}
|
||||
<InputSelectedBadge inputSelected="agent" onReject={() => onClearSeededRun?.()} />
|
||||
{#if pendingRun}
|
||||
<InputSelectedBadge
|
||||
inputSelected="pending_run"
|
||||
onReject={() => pendingRun.decline()}
|
||||
/>
|
||||
{/if}
|
||||
<RunForm
|
||||
bind:scheduledForStr
|
||||
@@ -1130,9 +1135,9 @@
|
||||
detailed={false}
|
||||
bind:isValid
|
||||
runnable={script}
|
||||
runAction={runScript}
|
||||
{runAction}
|
||||
bind:args
|
||||
schedulable={true}
|
||||
schedulable={!pendingRun}
|
||||
bind:this={runForm}
|
||||
{jsonView}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* A chat tool call parked on a deployed item's run form, when the reader chose to confirm it
|
||||
* on the item's own page instead of on the card. The page seeds its form from `args` and
|
||||
* routes Run to `submit` rather than starting a job of its own — the tool starts the job when
|
||||
* the call resumes, so a page that also ran would start a second one for the same request.
|
||||
*
|
||||
* The page is the card's form, relocated. That is why it cannot offer what the card cannot:
|
||||
* the call carries arguments and nothing else, so scheduling, tag override and
|
||||
* invisible-to-owner are hidden while it is adopted. They come back the moment it is not.
|
||||
*/
|
||||
export type PendingRun = {
|
||||
/** Which call is waiting. The page seeds its form once per call rather than once per
|
||||
* mount: a tab already showing this item is reused for the next request, so a latch that
|
||||
* only remembered "seeded" would leave the previous call's arguments on screen. */
|
||||
toolCallId: string
|
||||
/** What the model proposed, already narrowed to the deployed schema by the tool. */
|
||||
args: Record<string, any>
|
||||
/** Hand the form's current arguments to the waiting call. False when it is no longer
|
||||
* waiting — a stopped turn, or a job that failed to start — so the page can say so
|
||||
* instead of leaving Run looking live. */
|
||||
submit: (args: Record<string, any>) => boolean
|
||||
/** Refuse the call, as the card's Cancel does. Not an undo of the prefill: there is
|
||||
* nothing to restore it to, and leaving the call parked with an emptied form would strand
|
||||
* the turn. `reason` is what the model is told instead of "cancelled" — for the page that
|
||||
* settles the call by running the item some other way, where reporting a refusal would
|
||||
* have the model say nothing ran. */
|
||||
decline: (reason?: string) => void
|
||||
}
|
||||
@@ -63,6 +63,11 @@
|
||||
subject?: 'flow' | 'agent'
|
||||
/** The host's own controls, after the model's in the composer's footer. */
|
||||
composerSettings?: import('svelte').Snippet
|
||||
/** A strip directly above the composer of the conversation on screen — the one
|
||||
* {@link offerMessage} puts a message in. The panels behind it get none: a banner about
|
||||
* the message in this composer would be false over a conversation the reader switched
|
||||
* away from. */
|
||||
inputPreface?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -78,7 +83,8 @@
|
||||
frame = 'top',
|
||||
conversationKind = 'deployed',
|
||||
subject = 'flow',
|
||||
composerSettings = undefined
|
||||
composerSettings = undefined,
|
||||
inputPreface = undefined
|
||||
}: Props = $props()
|
||||
|
||||
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
@@ -281,6 +287,7 @@
|
||||
{conversationKind}
|
||||
{subject}
|
||||
extraSettings={composerSettings}
|
||||
inputPreface={shown ? inputPreface : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
subject?: 'flow' | 'agent'
|
||||
/** The host's own controls, after the model's in the composer's footer. */
|
||||
extraSettings?: import('svelte').Snippet
|
||||
/** A strip directly above the composer, for something the host has to say about the
|
||||
* message that is about to be sent. */
|
||||
inputPreface?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -74,7 +77,8 @@
|
||||
wideLayout = false,
|
||||
conversationKind = 'deployed',
|
||||
subject = 'flow',
|
||||
extraSettings = undefined
|
||||
extraSettings = undefined,
|
||||
inputPreface = undefined
|
||||
}: Props = $props()
|
||||
|
||||
const operatingUser = useOperatingUser()
|
||||
@@ -329,6 +333,7 @@
|
||||
hideModeSelector
|
||||
{wideLayout}
|
||||
{emptyHint}
|
||||
{inputPreface}
|
||||
footerSettings={modalSchema || showModelButton || extraSettings ? footerSettings : undefined}
|
||||
placeholder="Send a message to run the {subject}"
|
||||
disabled={deploymentInProgress || !!modelGap || !!wrongKindReason}
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
/** `agent` is the odd one: the others name where the arguments in the form came from
|
||||
* and put them back on reject, while this one reports that a chat tool filled the form
|
||||
* when it opened the page — rejecting only drops the note, since there is nothing to
|
||||
* put back. */
|
||||
inputSelected: 'history' | 'captures' | 'saved' | 'ai' | 'agent' | undefined
|
||||
/** `pending_run` is the odd one: the others report where the arguments in the form came
|
||||
* from and undo that substitution on reject, while this one reports a chat tool call
|
||||
* waiting on the form and declines it. */
|
||||
inputSelected: 'history' | 'captures' | 'saved' | 'ai' | 'pending_run' | undefined
|
||||
labelColor?: string
|
||||
className?: string
|
||||
acceptButton?: Snippet
|
||||
@@ -20,7 +19,11 @@
|
||||
let { inputSelected, className = '', acceptButton, onReject, labelColor = '' }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="min-h-[38px]">
|
||||
<!-- The reserved slot keeps the form from jumping as the badge toggles with the input
|
||||
picker's selection. A `pending_run` badge does not toggle — it stands for as long as
|
||||
the call is parked — so it holds no space open, which in a short pane is enough to
|
||||
push the form's footer out of sight. -->
|
||||
<div class={inputSelected === 'pending_run' ? '' : 'min-h-[38px]'}>
|
||||
<div
|
||||
class={twMerge(
|
||||
'rounded-md flex flex-row gap-2 items-center py-1 px-2 w-fit',
|
||||
@@ -30,8 +33,8 @@
|
||||
)}
|
||||
>
|
||||
<p class={twMerge(classes['info'].descriptionClass, 'text-xs px-2', labelColor)}>
|
||||
{#if inputSelected === 'agent'}
|
||||
The agent filled these arguments
|
||||
{#if inputSelected === 'pending_run'}
|
||||
The agent wants to run this
|
||||
{:else}
|
||||
Using {inputSelected === 'history'
|
||||
? 'historic'
|
||||
|
||||
@@ -66,46 +66,10 @@
|
||||
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.
|
||||
//
|
||||
// Taken from the runtime once and held here, because the request belongs to the turn that
|
||||
// made it: left in the map it would re-seed every later open of this item's page. The item
|
||||
// it was filed under rides along, since this tab re-points to another item in place rather
|
||||
// than remounting — a seed held without its key would land in the next item's form,
|
||||
// replacing its own defaults, under a note crediting the agent for them.
|
||||
//
|
||||
// `pending` is what the page has still to apply, and the detail page clears it by reporting
|
||||
// that it has. The two are separate because the detail page remounts under this one (a
|
||||
// deploy, a refresh, a version pin) and comes back with its own latch reset: without that,
|
||||
// each remount would push the proposal in again, over whatever the reader has since typed,
|
||||
// and bring back a note they had dismissed. The agent proposed once.
|
||||
let held = $state<
|
||||
| {
|
||||
kind: SessionTargetKind
|
||||
path: string
|
||||
args: Record<string, any>
|
||||
seq: number
|
||||
pending: boolean
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
const forThisItem = $derived(held?.kind === kind && held.path === path ? held : undefined)
|
||||
const seededRun = $derived(
|
||||
forThisItem?.pending ? { args: forThisItem.args, seq: forThisItem.seq } : undefined
|
||||
)
|
||||
/** The note stands until the reader dismisses it, which outlives the one application. */
|
||||
const seededByAgent = $derived(!!forThisItem)
|
||||
$effect(() => {
|
||||
const incoming = runtime.seededRunArgsFor(kind, path)
|
||||
if (!incoming) return
|
||||
const [k, p] = [kind, path]
|
||||
untrack(() => {
|
||||
held = { kind: k, path: p, ...incoming, pending: true }
|
||||
runtime.clearSeededRunArgs(k, p)
|
||||
})
|
||||
})
|
||||
// The call waiting on this item's run form, when the reader chose to confirm it here
|
||||
// rather than on the card. Read live from the runtime, so it disappears the moment the
|
||||
// call settles — by Run, by Cancel, by a stopped turn or by this tab closing.
|
||||
const pendingRun = $derived(runtime.pendingRunFor(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
|
||||
@@ -245,10 +209,7 @@
|
||||
{onNavigate}
|
||||
{active}
|
||||
embedded
|
||||
{seededRun}
|
||||
{seededByAgent}
|
||||
onSeedApplied={() => held && (held = { ...held, pending: false })}
|
||||
onClearSeededRun={() => (held = undefined)}
|
||||
{pendingRun}
|
||||
onLoadState={setLoadState}
|
||||
/>
|
||||
{:else if kind === 'flow'}
|
||||
@@ -259,10 +220,7 @@
|
||||
{onNavigate}
|
||||
{active}
|
||||
embedded
|
||||
{seededRun}
|
||||
{seededByAgent}
|
||||
onSeedApplied={() => held && (held = { ...held, pending: false })}
|
||||
onClearSeededRun={() => (held = undefined)}
|
||||
{pendingRun}
|
||||
onLoadState={setLoadState}
|
||||
/>
|
||||
{:else}
|
||||
|
||||
@@ -676,6 +676,15 @@ export class SessionPreviewTabs {
|
||||
this.#flush()
|
||||
}
|
||||
|
||||
/** Point one known tab at `url`. For a caller that found the tab itself — the deployed
|
||||
* page that confirmed a run becomes that run, and only the caller knows which page. */
|
||||
retargetById(id: string, url: string): void {
|
||||
const tab = this.#tabs.find((t) => t.id === id)
|
||||
if (!tab) return
|
||||
retargetTab(tab, url)
|
||||
this.#flush()
|
||||
}
|
||||
|
||||
setCollapsed(collapsed: boolean): void {
|
||||
if (this.#collapsed === collapsed) return
|
||||
this.#collapsed = collapsed
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SvelteMap } from 'svelte/reactivity'
|
||||
import type { PendingRun } from '$lib/components/details/pendingRun'
|
||||
import { get } from 'svelte/store'
|
||||
import { base } from '$lib/base'
|
||||
import { invalidate as invalidateWorkspaceItems } from '$lib/components/workspacePicker'
|
||||
@@ -223,13 +224,8 @@ 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<string, any>; seq: number } | undefined
|
||||
/** Drop that note; the arguments themselves stay in the form. */
|
||||
clearSeededRunArgs(kind: SessionTargetKind, path: string): void
|
||||
/** The chat tool call waiting on this item's deployed run form, if one adopted it. */
|
||||
pendingRunFor(kind: SessionTargetKind, path: string): PendingRun | undefined
|
||||
/** 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
|
||||
@@ -567,32 +563,65 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
// Only persist a real width; undefined means "never resized" (defaults to 50).
|
||||
if (snap.previewSize != null) setSessionPreviewSize(session.id, snap.previewSize)
|
||||
},
|
||||
onTabsChanged: pruneEditorCells,
|
||||
onTabsChanged: () => {
|
||||
pruneEditorCells()
|
||||
declineAdoptionsWithoutTab()
|
||||
},
|
||||
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<string, { args: Record<string, any>; seq: number }>()
|
||||
const seededKey = (kind: SessionTargetKind, path: string) => `${kind}:${path}`
|
||||
let seededRunSeq = 0
|
||||
// Deployed pages that have taken over a parked run form, keyed by the item they show. Not
|
||||
// persisted: a parked call does not survive a reload, so a restored tab comes back as a
|
||||
// plain deployed page.
|
||||
const adoptedRuns = new SvelteMap<string, { toolCallId: string; args: Record<string, any> }>()
|
||||
const adoptionKey = (kind: SessionTargetKind, path: string) => `${kind}:${path}`
|
||||
|
||||
/** 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<string, any>; seq: number } | undefined {
|
||||
return seededRunArgs.get(seededKey(kind, path))
|
||||
/** The call waiting on this item's run form, for the viewer that renders it. Read live
|
||||
* rather than copied, so it disappears the moment the call settles however it settles. */
|
||||
function pendingRunFor(kind: SessionTargetKind, path: string): PendingRun | undefined {
|
||||
const adopted = adoptedRuns.get(adoptionKey(kind, path))
|
||||
if (!adopted) return undefined
|
||||
return {
|
||||
toolCallId: adopted.toolCallId,
|
||||
args: adopted.args,
|
||||
// False when the call is no longer waiting — a turn stopped out from under the page,
|
||||
// or a job that failed to start. The page says so rather than leaving Run dead.
|
||||
submit: (args) => manager.handleRunFormSubmit(adopted.toolCallId, args),
|
||||
// Only a string is a reason. `decline` reads as an event handler, and a caller that
|
||||
// wires it straight to a component's `on:click` would otherwise hand the model a
|
||||
// `CustomEvent` as the explanation of why its call was settled.
|
||||
decline: (reason) =>
|
||||
manager.handleRunFormCancel(
|
||||
adopted.toolCallId,
|
||||
typeof reason === 'string' ? reason : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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))
|
||||
/** Drop the adoption, leaving the page as the plain deployed page it also is. Called when
|
||||
* the call settles by any route: Run, Cancel, a stopped turn, the tab closing. */
|
||||
function dropAdoption(toolCallId: string): void {
|
||||
for (const [key, adopted] of [...adoptedRuns]) {
|
||||
if (adopted.toolCallId === toolCallId) adoptedRuns.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Closing the tab declines the call, the contract the run form tab already has: the tab is
|
||||
// the call's presence in the panel. Driven off the tab set rather than a close handler so a
|
||||
// tab dropped by a reset or a session switch counts too.
|
||||
function declineAdoptionsWithoutTab() {
|
||||
for (const [key, adopted] of [...adoptedRuns]) {
|
||||
const [kind, path] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)]
|
||||
const stillOpen = previewTabs.tabs.some((t) => {
|
||||
const slot = resolvePreviewTab(t.url)
|
||||
return slot.kind === 'viewer' && slot.viewerKind === kind && slot.path === path
|
||||
})
|
||||
if (stillOpen) continue
|
||||
adoptedRuns.delete(key)
|
||||
if (manager.isRunFormPending(adopted.toolCallId)) {
|
||||
manager.handleRunFormCancel(adopted.toolCallId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Let the jobs tray open a run in this session's preview panel (as an iframe
|
||||
@@ -612,18 +641,17 @@ 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.openDeployedRunPageHandler = ({ kind, path, summary, args }) => {
|
||||
// The reader asked, from the card, to confirm a DEPLOYED run on the item's own page rather
|
||||
// than on the card's form. The page takes over the parked call: its Run hands the
|
||||
// arguments back, so the tool still owns the run and the card still gets its logs.
|
||||
manager.openRunOnDeployedPage = ({ toolCallId, 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. 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 })
|
||||
adoptedRuns.set(adoptionKey(kind, path), { toolCallId, args })
|
||||
const viewTarget = { ...target, item: { ...target.item, summary }, mode: 'view' as const }
|
||||
// A viewer already open on an older version of this item keeps that pin when it is
|
||||
// merely focused, and Run there executes that version — not the deployment these
|
||||
@@ -649,16 +677,38 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
}
|
||||
return true
|
||||
}
|
||||
manager.closeRunForm = (toolCallId) => previewTabs.closeRunForm(toolCallId)
|
||||
manager.closeRunForm = (toolCallId) => {
|
||||
// The page outlives the call it adopted — it is still the item's deployed page — so
|
||||
// settling only drops the adoption, and the form goes back to running on its own.
|
||||
// Reached however the call ends, a job that failed to start included, which is what
|
||||
// keeps a settled call from leaving an inert Run button behind.
|
||||
dropAdoption(toolCallId)
|
||||
previewTabs.closeRunForm(toolCallId)
|
||||
}
|
||||
manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => {
|
||||
previewTabs.retargetRunForm(toolCallId, `${base}/run/${jobId}?workspace=${workspace}`)
|
||||
const runHref = `${base}/run/${jobId}?workspace=${workspace}`
|
||||
// Either surface the call was waiting on becomes the run it started, in place.
|
||||
for (const [key, adopted] of [...adoptedRuns]) {
|
||||
if (adopted.toolCallId !== toolCallId) continue
|
||||
adoptedRuns.delete(key)
|
||||
const [kind, path] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)]
|
||||
const tab = previewTabs.tabs.find((t) => {
|
||||
const slot = resolvePreviewTab(t.url)
|
||||
return slot.kind === 'viewer' && slot.viewerKind === kind && slot.path === path
|
||||
})
|
||||
if (tab) previewTabs.retargetById(tab.id, runHref)
|
||||
}
|
||||
previewTabs.retargetRunForm(toolCallId, runHref)
|
||||
}
|
||||
// Read off the tab list rather than the slot's lifecycle: a tab the user has switched
|
||||
// away from is unmounted but still open, and the card must keep its form hidden until it
|
||||
// is closed. A resolver, like activePreviewResolver: the reader's own $derived subscribes
|
||||
// to `tabs` through it, and the runtime is not inside an effect root to push from.
|
||||
// An adopted deployed page counts: its form is this call's form, so the card must not
|
||||
// mount a second one there either.
|
||||
manager.isRunFormInPreview = (toolCallId) =>
|
||||
previewTabs.tabs.some((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId)
|
||||
previewTabs.tabs.some((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId) ||
|
||||
[...adoptedRuns.values()].some((a) => a.toolCallId === toolCallId)
|
||||
|
||||
manager.openArtifact = (id, name, version) => {
|
||||
previewTabs.open({ type: 'artifact', id, name, version })
|
||||
@@ -694,8 +744,7 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
flowCell,
|
||||
loadedEditorPath,
|
||||
deployedRevision,
|
||||
seededRunArgsFor,
|
||||
clearSeededRunArgs,
|
||||
pendingRunFor,
|
||||
logTabUsage,
|
||||
|
||||
async loadFlow(workspace: string, path: string, force = false) {
|
||||
|
||||
Reference in New Issue
Block a user