Files
windmill/frontend/src/lib/components/DynamicInput.svelte
T
AlexRV12andClaude Opus 5 a6abf2c8a7 feat: run and test scripts from the AI chat through an argument form (#11001)
* fix: disable a dynamic input when its schema field is disabled

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* refactor: extract the run form's argument hygiene into job_args

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: give Tabs an opt-in sliding selection indicator

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* refactor: share the chat's scroll-fade measurement

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: give the chat a run-form contract and incremental job output

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: run and test a script from the chat through an argument form

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: carry a chat run's card and job across saves and reloads

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: render a chat run as a tool call row with its form, logs and result

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: open a pending run form in the sessions preview pane

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* test: benchmark running a deployed script from the chat

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: offer a test run's dynamic options from the draft it previews

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

* docs: say that a test run's dynselect helper executes on form display

* fix: send a schema default the model omitted when yolo skips the form

* fix: infer a test run's schema when the stored one declares no properties

* docs: tighten the note on the form's mount-time helper job

* fix: apply a nested schema default the bypass posture counts as answered

* fix: apply a declared default to a null value and an optional nested field

* fix: check required fields inside a supplied optional object before bypassing

* fix: read required args as own properties before bypassing the form

* fix: stop the turn from the run form's action row in the preview panel

* refactor: drop the run-form prediction and share its secret minting

* refactor: prefill a proposed secret instead of emptying the field

* docs: correct the comments the run-form prediction left behind

* fix: keep a proposed secret out of the chat's stored messages

* docs: say what a literal secret argument now does

* test: restore the copilotInfo export the aiStore mock omits

* docs: cut the run form's helper-script note to its constraints

* refactor: settle a run form from one entry and fetch a job's logs once

* fix: separate colliding secret paths, gate plan mode, keep polled logs

* fix: mint before the form opens, skip empty fields, show what ran

* revert: mint a run form's secrets at submit, not before it opens

* fix: settle a cancelled run card on the form's arguments, not the proposal

* fix: settle a stopped run form like a cancelled one, and keep an empty secret empty

* fix: snapshot a run's arguments before minting its secrets

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:31:58 +00:00

234 lines
6.8 KiB
Svelte

<script lang="ts" module>
function validSelectObject(x): string | undefined {
if (typeof x != 'object') {
return JSON.stringify(x) + ' is not an object'
}
let keys = Object.keys(x)
if (!keys.includes('value') || !keys.includes('label')) {
return JSON.stringify(x) + ' does not contain value or label field'
}
if (typeof x['label'] != 'string') {
return JSON.stringify(x) + ' label is not a string'
}
return
}
</script>
<script lang="ts">
import { usePromise } from '$lib/svelte5Utils.svelte'
import JobLoader, { type Callbacks } from './JobLoader.svelte'
import Select from './select/Select.svelte'
import MultiSelect from './select/MultiSelect.svelte'
import { safeSelectItems } from './select/utils.svelte'
import Tooltip from './Tooltip.svelte'
import { Loader2 } from 'lucide-svelte'
import { type DynamicInput } from '$lib/utils'
import { deepEqual } from 'fast-equals'
import { untrack } from 'svelte'
import { getHelperEntrypointArgs } from '$lib/infer'
interface Props {
value?: any
helperScript?: DynamicInput.HelperScript
format: string
otherArgs?: Record<string, any>
name: string
/** Workspace the helper script runs in; defaults to the nav workspace. */
workspace?: string
/** Reaches the fallback editor too, which is what renders when there is no
* `helperScript` — a caller disabling this argument means all of it. */
disabled?: boolean
}
let {
value = $bindable(),
helperScript,
format,
otherArgs: otherArgs,
workspace = undefined,
disabled = false
}: Props = $props()
let [inputType, entrypoint] = $derived(format.includes('-') ? format.split('-', 2) : [format, ''])
let isMultiple = $derived(inputType === 'dynmultiselect')
let isSelect = $derived(inputType === 'dynselect' || inputType === 'dynmultiselect')
$effect.pre(() => {
if (isMultiple && value === undefined) {
value = []
}
})
let resultJobLoader: JobLoader | undefined = $state()
// loadInit:false — the $effect below owns the first refresh once
// resultJobLoader is bound; without this the promise is kicked off twice.
let _items = usePromise(getItemsFromOptions, { clearValueOnRefresh: false, loadInit: false })
let items = $derived(_items.value)
let filterText: string = $state('')
let open: boolean = $state(false)
async function getItemsFromOptions() {
return new Promise<{ label: string; value: any }[]>((resolve, reject) => {
let cb: Callbacks = {
doneResult({ result }) {
if (!result || !Array.isArray(result)) {
if (result?.error?.message && result?.error?.name) {
reject(
`Error in ${inputType} function execution: ` +
result?.error?.name +
' - ' +
result?.error?.message
)
} else {
reject('Result was not an array but ' + JSON.stringify(result, null, 2))
}
return
}
if (result.length == 0) resolve([])
if (result.every((x) => typeof x == 'string')) {
result = result.map((x) => ({ label: x, value: x }))
} else if (result.find((x) => validSelectObject(x) != undefined)) {
reject(validSelectObject(result.find((x) => validSelectObject(x) != undefined)))
return
}
resolve(result)
},
cancel: () => reject(),
doneError({ id, error }) {
reject(error)
}
}
resultJobLoader?.runDynamicInputScript(
entrypoint,
helperScript!,
{ ...otherArgs, filterText, _ENTRYPOINT_OVERRIDE: entrypoint },
cb
)
})
}
let neverLoaded = $state(true)
$effect(() => {
if (_items.value && value !== undefined && isSelect) {
if (isMultiple && Array.isArray(value) && Array.isArray(_items.value)) {
const availableValues = new Set(_items.value.map((x) => x.value))
const filteredValue = value.filter((v) => availableValues.has(v))
if (filteredValue.length !== value.length) {
value = filteredValue
}
} else if (!isMultiple && value !== undefined) {
if (!_items.value.find((x) => x.value == value)) {
value = undefined
}
}
}
})
let lastArgs = $state.snapshot(untrack(() => otherArgs))
let timeout: number | undefined = $state()
let nargs = $state($state.snapshot(untrack(() => otherArgs)))
$effect(() => {
otherArgs
untrack(() => clearTimeout(timeout))
timeout = setTimeout(() => {
nargs = $state.snapshot(otherArgs)
}, 1000)
})
// Parameter names declared by the helper function. When known, we restrict
// the change-detection to only those keys so typing in unrelated form fields
// no longer retriggers the dynselect job. `undefined` means we couldn't
// determine the signature → fall back to a full-args comparison.
let helperParams = $state<Set<string> | undefined>(undefined)
$effect(() => {
const script = helperScript
const ep = entrypoint
if (!script) {
helperParams = undefined
return
}
let cancelled = false
void getHelperEntrypointArgs(script, ep || undefined).then((params) => {
if (!cancelled) helperParams = params
})
return () => {
cancelled = true
}
})
function filterArgs(args: Record<string, any> | undefined) {
if (!args || !helperParams) return args
const filtered: Record<string, any> = {}
for (const k of helperParams) {
if (k in args) filtered[k] = args[k]
}
return filtered
}
$effect(() => {
;[filterText, entrypoint, helperScript]
if (
resultJobLoader &&
(open || neverLoaded || !deepEqual(filterArgs(lastArgs), filterArgs(nargs)))
) {
neverLoaded = false
lastArgs = $state.snapshot(otherArgs)
_items.refresh()
}
})
</script>
{#if helperScript}
<JobLoader onlyResult workspaceOverride={workspace} bind:this={resultJobLoader} />
<div class="w-full flex-col flex">
{#if inputType === 'dynmultiselect'}
<MultiSelect
bind:value
items={safeSelectItems(items || [])}
placeholder="Select items"
noItemsMsg={_items.status === 'loading' ? 'Loading...' : 'No items found'}
disabled={disabled || _items.status === 'loading'}
/>
{:else if inputType === 'dynselect'}
<Select
bind:value
bind:open
{items}
bind:filterText
loading={!open && _items.status === 'loading'}
{disabled}
clearable
noItemsMsg={_items.status === 'loading' ? 'Loading...' : 'No items found'}
/>
{:else}
<!-- Future dynamic input types can be added here -->
<div class="text-red-400 text-sm">
Unsupported dynamic input type: {inputType}
</div>
{/if}
{#if _items.error}
<div class="text-red-400 text-2xs">
error: <Tooltip>{_items.error}</Tooltip>
</div>
{/if}
</div>
{:else}
<div class="flex flex-col gap-1 w-full">
<div class="text-xs text-primary"
>Dynamic input ({inputType}) is not available in this mode, write value directly</div
>
{#await import('$lib/components/JsonEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default code={JSON.stringify(value, null, 2)} {disabled} bind:value />
{/await}
</div>
{/if}