Files
windmill/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte
T
hugocasaandClaude Opus 5.5 8dd46a4d14 feat: build, test and run standalone ai agents from ai sessions (#11954)
* feat: guidance for building standalone ai agents in ai sessions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: agent guidance for resource-only sessions, no flow tools

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: tell the user to deploy a drafted tool script with its agent

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: scope plain agent values, document tool_inputs, list agents in get_instructions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: test and run ai agents from ai sessions, open agents in their editor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: full-bleed agent editor tabs, back to the run form for agent tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: describe read-and-run as running agents too, pin agent run links

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: host the agent editor natively in session tabs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: switch the agent editor to tabs below 900px

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: name an agent's session tab after its summary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: show deployed agents in session tabs, open and run them from the chat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: deploy agents from the chat, report agent tabs as the active editor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: pick agents from the tab picker, name their tabs, scope the agent page to its workspace

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: leave open editors alone on agent writes, read the agent page's workspace user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: refresh an agent's deployed page when the chat deploys or deletes it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: name agent tabs before they load, mark agents in the changes drawer, stay on a deleted agent's tab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep a cleared agent summary out of its tab name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the resource drawer's json option that the resources list still passes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: open an agent in an AI session from its pages, fix review findings on agent tabs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: name the reserved agent tool names in the agent guidance

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: reload an agent's editor on a discard, open a new agent in a session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show agents as agents on compare and deploy, name a never-deployed one by its path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep a deployed agent's type in its draft diff

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: link a fork row to the agent editor only on the side that holds the agent

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: guard agent page paths, mark drafted agents in the picker, side-aware agent rows on fork compare

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:46:43 +01:00

254 lines
11 KiB
Svelte

<script lang="ts">
import { onMount, tick, untrack } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { Play, X } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { processSecretArgs } from '$lib/components/secretArgUtils'
import { enforceDisabledDefaults, resetKeysToast } from '$lib/components/job_args'
import { sendUserToast } from '$lib/utils'
import { getAiChatManager } from './aiChatManagerContext'
import { PLAN_MODE_MESSAGES } from './planModeMessages'
import { USE_MODE_WITHDRAWN } from './workMode'
import { scrollFades } from './scrollFades.svelte'
import type { RunFormDisplay } from './shared'
// Never the imported singleton: submitting has to resolve the pending callback of
// the manager that opened this form, which in a session is a per-pane one.
const aiChatManager = getAiChatManager()
interface Props {
toolCallId: string
runForm: RunFormDisplay
/** `card` caps its own height inside the chat transcript; `pane` fills the preview
* tab it was opened into and lets the fields take the whole height. */
layout?: 'card' | 'pane'
}
let { toolCallId, runForm, layout = 'card' }: Props = $props()
// The chat's workspace, not the globally-active one: a session may be acting on a
// fork, and that is where the job runs — so the pickers and the ephemeral secret
// variables have to resolve there too.
const workspace = $derived(aiChatManager.operatingWorkspace)
// The manager's draft, not a copy of its own: the chat card and the preview pane are two
// views of one form, and moving between them has to keep what was typed. untrack because
// the message is replaced on every patch to the card, and re-seeding from a later copy of
// it would discard those edits.
const draft = untrack(() => aiChatManager.runFormDraft(toolCallId, runForm))
const runnableKind = $derived(runForm.runnableKind ?? 'script')
const runnableNoun = $derived(runForm.cardNoun ?? runnableKind)
const staleFormToast = () =>
sendUserToast(`This run form is no longer active — ask again to run the ${runnableNoun}.`, true)
const properties = $derived(draft.schema?.properties ?? {})
const hasArgs = $derived(Object.keys(properties).length > 0)
let isValid = $state(true)
// Off the manager, not this instance: moving the form between the card and the preview
// panel unmounts it, and a flag that died with it would re-arm both buttons mid-submit.
const submitting = $derived(aiChatManager.isRunFormSubmitting(toolCallId))
let cardNode = $state<HTMLDivElement | undefined>()
// The picker moves while a form sits open, so this is live state, not mount-time. It holds
// off the entrypoint a `dynselect-` argument runs, which does not wait for Run. A password
// field mints at mount, too early to hold off here, so `runThroughForm` gates that instead.
const planMode = $derived(aiChatManager.planModeActive)
// A test run previews undeployed code, which Use withholds as plan mode withholds every run.
const refusal = $derived(
planMode
? PLAN_MODE_MESSAGES.runFormRefused
: runForm.kind === 'test' && aiChatManager.useModeActive
? USE_MODE_WITHDRAWN.testRunRefused
: undefined
)
onMount(() => {
void tick().then(() => cardNode?.scrollIntoView({ block: 'nearest' }))
})
const fades = scrollFades()
const { container: fadeContainer, content: fadeContent, measure: measureFades } = fades
// The two hosts stand on different surfaces — the chat card on the tool call's own, the
// preview tab on the raised one — and a fade has to end in the colour behind it.
const fadeTo = $derived(
layout === 'pane'
? 'bg-gradient-to-t from-surface-tertiary via-surface-tertiary/60 to-transparent'
: 'bg-gradient-to-t from-surface via-surface/60 to-transparent'
)
async function run() {
if (!isValid) return
// Before processSecretArgs, not after: a card restored from history outlives the
// manager that opened it, so submitting would mint an ephemeral secret variable
// per click and still run nothing.
if (!aiChatManager.isRunFormPending(toolCallId)) {
staleFormToast()
return
}
// Ahead of processSecretArgs, which writes ephemeral variables to the workspace: the
// mode picker moves while a form sits pending, and the re-gate on the other side of the
// callback runs too late to unmake a write plan mode or Use promised not to do.
if (refusal) {
sendUserToast(refusal, true)
return
}
if (!aiChatManager.beginRunFormSubmit(toolCallId)) return
// Only the disabled fields, and only because `RunForm` does the same before its own
// run: what the card showed is otherwise what runs. Re-filtering it here would delete
// the user's own typing between Run and the job — a free-form field the form gave a
// JSON editor to holds keys no schema names, and they are still theirs.
const { args: enforced, resetKeys } = enforceDisabledDefaults(draft.args ?? {}, draft.schema)
if (resetKeys.length > 0) {
sendUserToast(resetKeysToast(resetKeys))
}
let processed: Record<string, any>
try {
processed = await processSecretArgs(enforced, draft.schema as any, workspace)
} catch (e) {
aiChatManager.endRunFormSubmit(toolCallId)
sendUserToast('Failed to process sensitive args: ' + e, true)
return
}
// The callback can still go away across the processSecretArgs round trip, and by
// then the ephemeral variables exist — say so rather than leaving a dead button.
if (!aiChatManager.handleRunFormSubmit(toolCallId, processed)) {
aiChatManager.endRunFormSubmit(toolCallId)
staleFormToast()
}
}
</script>
<!-- The card's heading and its chrome belong to RunScriptCard, which renders this form
as one phase of the same card. The keyboard-scope marker says a form owns the keys
here, so a list's own shortcuts stand down (ItemsList's SKIP_SELECTOR) — it belongs
on this phase only, which is why a settled card drops it with the form. -->
<!-- font-main because the card hosting this in the chat is a tool row, and those are
font-mono throughout: a field label is UI text, and the pane copy of this same form
already reads that way. (Not font-sans — this Tailwind config defines main and mono.) -->
<div
bind:this={cardNode}
class={twMerge('flex flex-col font-main', layout === 'pane' ? 'h-full min-h-0' : 'pt-3')}
data-chat-keyboard-scope="run-args-form"
>
<!-- Only the fields scroll, sized by their content rather than filling the host: the actions
follow the last field of a short form, and a long one scrolls under them rather than
pushing the Run button — and the lines naming what the form dropped — below the fold. -->
<div class={twMerge('relative flex flex-col', layout === 'pane' ? 'min-h-0' : '')}>
<!-- `pl-9` is the rail the pane header's title stands on, its padding plus the icon and
the gap beside it, so the tab reads down one edge. The card has no such rail to meet
and reserves the gutter on both sides instead, which keeps its fields centred. -->
<div
use:fadeContainer
onscroll={measureFades}
class={twMerge(
'overflow-y-auto',
layout === 'pane' ? 'min-h-0 pl-9 pr-3' : 'max-h-[min(28rem,50vh)] px-3'
)}
style={layout === 'pane'
? 'scrollbar-gutter: stable;'
: 'scrollbar-gutter: stable both-edges;'}
>
<div use:fadeContent>
{#if hasArgs}
<!-- Passing `helperScript` is what lets DynamicInput run a job before Run: it
executes the `dynselect-` entrypoint on mount, carrying the other args as proposed,
and Cancel does not undo it. Withheld under plan mode, which promised no writes, and from
a test run in Use, whose helper is undeployed code too.
A test run names its draft by code: `deployed` resolves a stale version, or none. -->
<SchemaForm
bind:schema={draft.schema}
helperScript={refusal
? undefined
: runForm.kind === 'test'
? runForm.code && runForm.lang
? { source: 'inline', code: runForm.code, lang: runForm.lang }
: undefined
: { source: 'deployed', path: runForm.path, runnable_kind: runnableKind }}
disabled={!!refusal}
{workspace}
prettifyHeader
bind:isValid
bind:args={draft.args}
/>
{:else}
<p class="text-xs text-secondary">This {runnableNoun} takes no arguments.</p>
{/if}
</div>
</div>
<!-- Only what is still below, and only while there is some: the fade the tool cards draw
under their own content, over the scroller rather than inside it. Nothing at the top —
having scrolled down is itself the knowledge that there is more up there. -->
{#if fades.bottom}
<div
class={twMerge(
'pointer-events-none absolute inset-x-0 bottom-0 h-[min(2.5rem,35%)]',
fadeTo
)}
></div>
{/if}
</div>
<!-- One region with the actions: these lines report on the run the button below launches,
and separating them would read as two subjects. No padding of its own over the fields:
every one of them ends on the room ArgInput keeps for a validation message, and adding
to it would sit the buttons twice as far below the last field as the first label sits
from the top. A form with no fields keeps none of that room, so there it comes back. -->
<div
class={twMerge(
'flex flex-col gap-2 px-3 pb-3',
hasArgs ? '' : 'pt-3',
layout === 'pane' ? 'pl-9' : ''
)}
>
{#if runForm.clearedKeys?.length}
<p class="text-2xs text-secondary">
Sent in a shape this field has no reading of, so it opened empty:
<span class="font-mono">{runForm.clearedKeys.join(', ')}</span>
</p>
{/if}
{#if runForm.resetKeys?.length}
<p class="text-2xs text-secondary">
Disabled by this {runnableNoun}, so it will run with its default:
<span class="font-mono">{runForm.resetKeys.join(', ')}</span>
</p>
{/if}
{#if runForm.strippedKeys?.length}
<p class="text-2xs text-secondary">
A file, so it opened empty for you to attach:
<span class="font-mono">{runForm.strippedKeys.join(', ')}</span>
</p>
{/if}
{#if refusal}
<p class="text-2xs text-secondary">{refusal}</p>
{/if}
<!-- Reject then confirm at the end of the row, as ToolConfirmationFooter puts them: this
is a tool call being validated. Both rest while a submit is in flight — the ephemeral
variables exist by then, and cancelling would settle the call as declined on a run
already starting. Escape stops the turn from here and nowhere else in the form. -->
<div class="flex items-center justify-end gap-2" data-run-form-actions={toolCallId}>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: X }}
disabled={submitting}
onClick={() => aiChatManager.handleRunFormCancel(toolCallId)}
>
Cancel
</Button>
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: Play }}
disabled={!isValid || submitting || !!refusal}
onClick={run}
>
Run
</Button>
</div>
</div>
</div>