Files
windmill/frontend/src/lib/components/FlowPreviewContent.svelte
T
hugocasaandClaude Opus 5 f037c73d10 feat(frontend): group the agent form and edit saved agents as drafts (#10880)
* feat(frontend): group the AI agent step form and edit saved agents in a modal

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

* feat: edit a saved AI agent through its own resource draft

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

* refactor: drop the agent fork-for-edit session now that edits live in a draft

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

* feat: edit ai_agent resources from the resources page with the agent editor

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

* fix: send a standalone agent's brain from the module when testing a step

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

* fix: keep the agent draft faithful to the resource it deploys to

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

* chore: add the sqlx cache entry for the eval subject rename

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

* refactor: share the module insert between the graph and the agent editor

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

* feat(frontend): open evals inside the agent editor, actions in its header

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

* feat(frontend): add tools from the agent editor and lighten its test pane

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

* fix(frontend): open an ai_agent deep link in the agent editor

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

* fix(frontend): drop the failed result badge on a step that never ran

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

* feat(frontend): head the agent editor's levels with a back control

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

* feat(frontend): drop connect and fill inputs from the agent editor

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

* feat(frontend): lighten the agent editor's run panel

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

* fix(frontend): stop a nested agent tool's config reading as AI-filled

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

* feat(frontend): offer only AI or static on an agent tool's inputs

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

* feat(frontend): give a saved agent's tool editor a static-only surface

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

* feat(frontend): open an agent tool in a drawer beside the agent

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

* feat(frontend): hide unset agent config in the run form

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

* refactor(frontend): share the input forms' pickers and s3 lookup

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

* chore(frontend): drop a dead agent-editor export and fix two stale comments

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

* fix(frontend): reach an ai_agent's resource-level settings and copilot

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

* fix(frontend): open an ai_agent's resource view as JSON, not the generic form

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

* fix(frontend): address review findings on the agent editor's draft and streaming

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

* fix(frontend): close the agent editor on a version restore, as the resource editor does

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

* fix(frontend): stop the provider picker auto-writing a kind, and clear review nits

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

* refactor(frontend): drop the fork-for-edit leftovers from the agent card

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

* fix(frontend): mount the agent editor in the dev flow editor and guard the deep-link race

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

* fix(frontend): deploy the agent config that was submitted, and refuse one no run could use

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

* refactor(frontend): build the agent editor's rows from the design-system button

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

* fix(frontend): keep a draft-only agent's draft, and let a blank MCP summary deploy

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

* fix(frontend): guard read-only agents, incomplete MCP tools and duplicate editor mounts

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

* fix: read-only agent editor, linked-card refresh, atomic eval rename

* fix: eval rename needs the privileged pool, per-workspace write access

* fix(frontend): drop the agent editor target when its mount goes away

* refactor: drop the agent rename work from this PR, unban the bindable defaults

* fix(frontend): refuse a renaming deploy and drop the copilot from static-only fields

* fix(frontend): mirror the worker's streaming rule and scope agent writes to their target

* fix(frontend): read runtime streaming as off and reset the drawer's json view

* fix(frontend): read an unsettled output_type as non-streaming too

* fix(frontend): let the showing modal claim an agent opened from inside it

* fix(frontend): keep in-flight edits, tool replacements and every linked step in sync

* fix(frontend): keep attachments in the run form and bind the agent ref to its tools

* fix(frontend): preview the agent as authored and re-evaluate step args on run

* fix(frontend): scope agent-editor ownership to the flow's workspace

* fix(frontend): drop the tool drill-in where there is no graph to select on

* fix(frontend): require a provider kind and keep one resource editor open at a time

* fix(frontend): keep legacy nulls, static-only text literal, and the handover anchor

* test(worker): pin the agent streaming default

* fix(frontend): let an AI-fillable input be switched to static

* fix(frontend): report agent editor background failures instead of floating them

* fix(frontend): keep the version pane's path alive while the editor closes

* fix(frontend): clear the anchor-keep flag at the start of each drawer session

* fix(frontend): preview the agent without its synthetic path, refresh the baseline on external writes

* refactor(frontend): drop the unverifiable baseline refresh, state the synthetic-path rule

* fix(frontend): keep the synthetic path out of agent tool test runs too

* refactor(frontend): host the agent editor under the agent's own path

* fix(frontend): mark an agent editor's host explicitly instead of inferring it from the path

* fix(frontend): discard linked-agent responses from before a deploy

* fix(frontend): keep a flow mount from claiming an agent editor's nested target

* feat(frontend): keep an agent used as a tool inside the agent being edited

* fix(frontend): reserve the agent editor's root module id

* docs(frontend): record why the agent editor previews under the agent's path

* fix(frontend): refuse to open or deploy a resource that is not an agent

* docs(frontend): put the scope-migration comment on the function it describes

* fix(frontend): refuse an agent path whose resource type is not proven

* fix(frontend): recheck the resource type before deploying, and keep expressions off static-only inputs

* fix(frontend): lazy-load the agent editor and slide its levels like the evals pane

* refactor: drop unreachable non-list tools check from agent deploy

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

* fix(frontend): clear text-only agent fields on image output, reserve the root id

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

* fix(frontend): keep the agent editor usable for a non-list tools value

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

* fix(frontend): stop the parked eval run list from taking arrow keys

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

* fix(frontend): report a non-list tools value on deploy instead of throwing

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

* fix(frontend): keep temperature editable for image output

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

* fix(frontend): skip non-object tool entries when rendering an agent

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

* fix(frontend): guard tool entry reads instead of copying the tool array

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

* fix(frontend): key tool rows by position so duplicate ids render

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:32:17 +02:00

687 lines
20 KiB
Svelte

<script lang="ts">
import {
type Job,
JobService,
type RestartedFrom,
type OpenFlow,
type ScriptLang
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Badge, Button } from './common'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import { runFlowPreview } from './flows/utils.svelte'
import { processSecretArgs } from './secretArgUtils'
import SchemaForm from './SchemaForm.svelte'
import SchemaFormWithArgPicker from './SchemaFormWithArgPicker.svelte'
import FlowStatusViewer from '../components/FlowStatusViewer.svelte'
import FlowProgressBar from './flows/FlowProgressBar.svelte'
import FlowExecutionStatus from './runs/FlowExecutionStatus.svelte'
import JobDetailHeader from './runs/JobDetailHeader.svelte'
import {
AlertTriangle,
Circle,
CornerDownLeft,
Download,
Loader2,
Play,
RefreshCw,
X
} from 'lucide-svelte'
import { sendUserToast, type StateStore } from '$lib/utils'
import { dfs } from './flows/dfs'
import { sliceModules } from './flows/flowStateUtils.svelte'
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
import Toggle from './Toggle.svelte'
import JsonInputs from './JsonInputs.svelte'
import { argsToJsonPayload } from '$lib/schema'
import type { Schema } from '$lib/common'
import FlowHistoryJobPicker from './FlowHistoryJobPicker.svelte'
import type { DurationStatus, GraphModuleState } from './graph'
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
import FlowChat from './flows/conversations/FlowChat.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import FlowRestartButton from './FlowRestartButton.svelte'
import { useNestedRestartState } from './useNestedRestartState.svelte'
import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording'
import { agentStreamingEnabled } from './flows/agentFormFields'
interface Props {
previewMode: 'upTo' | 'whole'
open: boolean
preventEscape?: boolean
jobId?: string | undefined
job?: Job | undefined
initial?: boolean
selectedJobStep?: string | undefined
selectedJobStepIsTopLevel?: boolean | undefined
selectedJobStepType?: 'single' | 'forloop' | 'branchall'
rightColumnSelect?: 'timeline' | 'node_status' | 'node_definition' | 'user_states'
branchOrIterationN?: number
scrollTop?: number
localModuleStates?: Record<string, GraphModuleState>
localDurationStatuses?: Record<string, DurationStatus>
onRunPreview?: (jobId?: string) => void
render?: boolean
onJobDone?: () => void
upToId?: string | undefined
customUi?: {
tagLabel?: string | undefined
}
suspendStatus: StateStore<Record<string, { job: Job; nb: number }>>
}
let {
previewMode = $bindable(),
open,
preventEscape = $bindable(false),
jobId = $bindable(undefined),
job = $bindable(undefined),
initial = $bindable(false),
selectedJobStep = $bindable(undefined),
selectedJobStepIsTopLevel = $bindable(undefined),
selectedJobStepType = $bindable('single'),
rightColumnSelect = $bindable('timeline'),
branchOrIterationN = $bindable(0),
scrollTop = $bindable(0),
localModuleStates = $bindable({}),
localDurationStatuses = $bindable({}),
onRunPreview,
render = false,
onJobDone,
upToId = undefined,
suspendStatus
}: Props = $props()
let previewExpandedSubflows: Record<
string,
{ modules: import('$lib/gen').FlowModule[]; groups?: any[] }
> = $state({})
const restart = useNestedRestartState({
selectedJobStep: () => selectedJobStep,
job: () => job,
graphModuleStates: () => localModuleStates,
expandedSubflows: () => previewExpandedSubflows
})
// `selectedJobStepType` and `selectedJobStepIsTopLevel` are still exposed as
// `$bindable` props to legacy parents (FlowPreviewButtons binds them but
// doesn't read them). Sync them out of the composable here so the prop
// surface stays unchanged.
$effect(() => {
selectedJobStepIsTopLevel = restart.selectedJobStepIsTopLevel
})
$effect(() => {
selectedJobStepType = restart.selectedJobStepType
})
let isRunning: boolean = $state(false)
let jsonView: boolean = $state(false)
let jsonEditor: JsonInputs | undefined = $state(undefined)
let schemaHeight = $state(0)
let isValid: boolean = $state(true)
let isOwner: boolean = $state(false)
export async function test(conversationId?: string): Promise<string | undefined> {
renderCount++
return await runPreview(previewArgs.val, undefined, conversationId)
}
const {
selectionManager,
previewArgs,
flowStateStore,
flowStore,
pathStore,
initialPathStore,
fakeInitialPath,
customUi,
executionCount,
devTempScriptRefs,
opWorkspace
} = $state(getContext<FlowEditorContext>('FlowEditorContext'))
// Acting workspace when previewing inside an AI session; else the nav workspace.
let opWs = $derived(opWorkspace?.() ?? $workspaceStore)
const dispatch = createEventDispatcher()
let renderCount: number = $state(0)
let schemaFormWithArgPicker: SchemaFormWithArgPicker | undefined = $state(undefined)
let currentJobId: string | undefined = $state(undefined)
let stepHistoryLoader = getStepHistoryLoaderContext()
let flowProgressBar: FlowProgressBar | undefined = $state(undefined)
// Recording: nothing is captured live — once the run completes, its id is
// remembered and the recording is built from the completed jobs on download.
let lastRecordingJobId: string | undefined = $state(undefined)
let recordedFlow: OpenFlow | undefined = undefined
let downloadingRecording: boolean = $state(false)
let recordingMode: boolean = $state(false)
export function setRecordingMode(on: boolean) {
recordingMode = on
}
let loadingHistory = $state(false)
let shouldUseStreaming = $derived.by(() => {
const modules = flowStore.val.value?.modules
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
if (lastModule?.value?.type !== 'aiagent') return false
return agentStreamingEnabled(lastModule.value)
})
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
if (previewMode === 'whole') {
return flowStore.val
} else {
const flow = stateSnapshot(flowStore).val as OpenFlow
const idOrders = dfs(flow.value.modules, (x) => x.id)
let upToIndex = idOrders.indexOf(upToId ?? selectionManager.getSelectedId() ?? '')
if (upToIndex != -1) {
flow.value.modules = sliceModules(flow.value.modules, upToIndex, idOrders)
}
return flow
}
}
let lastPreviewFlow: undefined | string = $state(undefined)
export async function runPreview(
args: Record<string, any>,
restartedFrom: RestartedFrom | undefined,
conversationId?: string | undefined
) {
let newJobId: string | undefined = undefined
if (stepHistoryLoader?.flowJobInitial !== false) {
stepHistoryLoader?.setFlowJobInitial(false)
}
try {
lastPreviewFlow = JSON.stringify(flowStore.val)
flowProgressBar?.reset()
const newFlow = extractFlow(previewMode)
if (recordingMode) {
// Any completed run in recording mode becomes downloadable, so pair
// the flow as it is for *this* run — not a stale earlier capture.
// Deep-cloned: extractFlow('whole') returns the live store object,
// and edits made before download must not rewrite the run's definition.
recordedFlow = JSON.parse(JSON.stringify(newFlow))
}
args = await processSecretArgs(args, flowStore.val.schema as any, opWs)
newJobId = await runFlowPreview(
args,
newFlow,
$pathStore,
restartedFrom,
conversationId,
devTempScriptRefs?.(),
opWorkspace?.()
)
jobId = newJobId
isRunning = true
if (inputSelected) {
savedArgs = $state.snapshot(previewArgs.val)
inputSelected = undefined
}
onRunPreview?.(newJobId)
} catch (e) {
sendUserToast('Could not run preview', true, undefined, e.toString())
isRunning = false
jobId = undefined
}
schemaFormWithArgPicker?.refreshHistory()
return newJobId
}
function onKeyDown(event: KeyboardEvent) {
if (open) {
switch (event.key) {
case 'Enter':
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
runPreview(previewArgs.val, undefined)
}
break
case 'Escape':
if (preventEscape) {
selectInput(undefined)
event.preventDefault()
event.stopPropagation()
}
break
}
}
}
let savedArgs = $state(previewArgs.val)
let inputSelected: 'captures' | 'history' | 'saved' | undefined = $state(undefined)
async function selectInput(input, type?: 'captures' | 'history' | 'saved' | undefined) {
if (!input) {
previewArgs.val = savedArgs
inputSelected = undefined
setTimeout(() => {
preventEscape = false
}, 100)
} else {
previewArgs.val = input
inputSelected = type
preventEscape = true
}
// Deselecting restores the args the same way selecting replaced them, so both branches
// owe the editor an overwrite — it holds a payload for the input being left behind.
jsonEditor?.setCode(
argsToJsonPayload(flowStore.val.schema as Schema | undefined, previewArgs.val)
)
}
export function refresh() {
renderCount++
}
async function recordAndTest() {
lastRecordingJobId = undefined
await runPreview(previewArgs.val, undefined)
}
async function downloadRecording() {
if (!lastRecordingJobId || downloadingRecording) return
downloadingRecording = true
try {
// A run started before recording mode was enabled captured no flow —
// fall back to the flow as it is now.
const recording = await buildFlowRecording(
opWs!,
lastRecordingJobId,
$pathStore,
recordedFlow ?? extractFlow(previewMode)
)
downloadRecordingJson(recording, `flow-recording-${$pathStore.replace(/\//g, '-')}`)
} catch (e: any) {
sendUserToast('Could not build the recording', true, undefined, e?.toString())
} finally {
downloadingRecording = false
}
}
let scrollableDiv: HTMLDivElement | undefined = $state(undefined)
function handleScroll() {
let newScroll = scrollableDiv?.scrollTop ?? 0
if (newScroll != 0 && render) {
scrollTop = newScroll
}
}
function onScrollableDivChange() {
if (scrollTop != 0 && scrollableDiv) {
scrollableDiv.scrollTop = scrollTop
}
}
$effect(() => {
scrollableDiv && render && untrack(() => onScrollableDivChange())
})
export async function cancelTest() {
isRunning = false
try {
jobId &&
(await JobService.cancelQueuedJob({
workspace: opWs ?? '',
id: jobId,
requestBody: {}
}))
} catch {}
}
export function getLocalModuleStates() {
return localModuleStates
}
export function getLocalDurationStatuses() {
return localDurationStatuses
}
export function getSuspendStatus() {
return suspendStatus
}
export function getIsRunning() {
return isRunning
}
export function getIsOwner() {
return isOwner
}
export function getJob() {
return job
}
export function flowHasChanged() {
return !!lastPreviewFlow && JSON.stringify(flowStore.val) != lastPreviewFlow
}
</script>
<svelte:window onkeydown={onKeyDown} />
<div class="flex flex-col space-y-2 h-screen bg-surface py-2 w-full" id="flow-preview-content">
{#if render}
<div class="flex flex-row w-full items-center gap-x-2 px-4">
<div class="w-8">
<Button
on:click={() => dispatch('close')}
startIcon={{ icon: X }}
iconOnly
unifiedSize="md"
variant="default"
btnClasses="hover:bg-surface-hover bg-surface-secondaryw-8 h-8 rounded-full p-0"
/>
</div>
{#if isRunning}
<div class="mx-auto flex items-center gap-2">
<Button
variant="accent"
destructive
on:click={async () => {
cancelTest()
}}
unifiedSize="md"
btnClasses="w-full max-w-lg"
loading={true}
clickableWhileLoading
>
Cancel
</Button>
{#if recordingMode}
<span class="text-xs text-red-500 font-medium flex items-center gap-1">
<Circle size={8} fill="currentColor" /> Recording
</span>
{/if}
</div>
{:else}
<div class="grow justify-center flex flex-row gap-2 items-center">
{#if jobId !== undefined && selectedJobStep !== undefined && restart.topLevelRestartable}
<!--
Nested-step restart is intentionally not surfaced from the editor
preview: the chain needs `flow_job_id` resolved at every level,
which only the backend can do (by walking the original
execution). Users who want nested restart should use the run
page, which goes through the dedicated restart API.
-->
<FlowRestartButton
{jobId}
{selectedJobStep}
selectedJobStepType={restart.selectedJobStepType}
restartBranchNames={restart.restartBranchNames}
presetIterationN={restart.topLevelLoopIteration}
iterationCounts={restart.iterationCounts}
onRestart={(stepId, branchOrIterationN) => {
runPreview(previewArgs.val, {
flow_job_id: jobId,
step_id: stepId,
branch_or_iteration_n: branchOrIterationN
})
}}
/>
{/if}
{#if !flowStore.val.value?.chat_input_enabled}
<Button
variant="accent"
startIcon={{ icon: isRunning ? RefreshCw : Play }}
size="sm"
btnClasses="w-full max-w-lg"
on:click={() =>
recordingMode ? recordAndTest() : runPreview(previewArgs.val, undefined)}
id="flow-editor-test-flow-drawer"
shortCut={{ Icon: CornerDownLeft }}
>
{#if previewMode == 'upTo'}
Test up to
<Badge baseClass="ml-1" color="indigo">
{selectionManager.getSelectedId()}
</Badge>
{:else if recordingMode}
Test flow and record
{:else}
Test flow
{/if}
</Button>
{/if}
{#if lastRecordingJobId && recordingMode}
<Button
variant="subtle"
unifiedSize="sm"
title="Download recording"
on:click={downloadRecording}
loading={downloadingRecording}
startIcon={{ icon: Download }}
>
Download recording
</Button>
{/if}
</div>
{/if}
</div>
{/if}
<div
bind:this={scrollableDiv}
class="overflow-y-auto grow flex flex-col pt-4 px-4"
onscroll={(e) => handleScroll()}
>
{#if render}
{#if flowStore.val.value?.chat_input_enabled}
<div class="flex flex-row justify-center w-full mb-6">
<FlowChat
useStreaming={shouldUseStreaming}
onRunFlow={async (userMessage, conversationId, additionalInputs) => {
await runPreview(
{ user_message: userMessage, ...(additionalInputs ?? {}) },
undefined,
conversationId
)
return jobId ?? ''
}}
hideSidebar={true}
path={$pathStore}
inputSchema={flowStore.val.schema}
/>
</div>
{:else}
<div>
<SchemaFormWithArgPicker
bind:this={schemaFormWithArgPicker}
runnableId={$initialPathStore}
stablePathForCaptures={$initialPathStore || fakeInitialPath}
runnableType={'FlowPath'}
workspace={opWs}
previewArgs={previewArgs.val}
on:openTriggers
on:select={(e) => {
selectInput(e.detail.payload, e.detail?.type)
}}
{isValid}
{jsonView}
>
<div class="w-full flex flex-row justify-between">
<InputSelectedBadge
onReject={() => schemaFormWithArgPicker?.resetSelected()}
{inputSelected}
/>
<div class="flex flex-row gap-2">
<Toggle
bind:checked={jsonView}
size="xs"
options={{
right: 'JSON',
rightTooltip: 'Fill args from JSON'
}}
lightMode
on:change={() => {
refresh()
}}
/>
</div>
</div>
{#if jsonView}
<div class="py-2" style="height: {Math.max(schemaHeight, 100)}px" data-schema-picker>
<JsonInputs
bind:this={jsonEditor}
on:select={(e) => {
if (e.detail) {
previewArgs.val = e.detail
}
}}
initialCode={argsToJsonPayload(
flowStore.val.schema as Schema | undefined,
previewArgs.val
)}
updateOnBlur={false}
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/>&nbsp;&nbsp;"foo": "12"<br/>}`}
/>
</div>
{:else}
{#key renderCount}
<div bind:clientHeight={schemaHeight} class="min-h-[40vh]">
<SchemaForm
noVariablePicker
compact
workspace={opWs}
schema={flowStore.val.schema}
bind:args={previewArgs.val}
on:change={() => {
savedArgs = $state.snapshot(previewArgs.val)
}}
bind:isValid
helperScript={flowStore.val.schema?.['x-windmill-dyn-select-code'] &&
flowStore.val.schema?.['x-windmill-dyn-select-lang']
? {
source: 'inline',
code: flowStore.val.schema['x-windmill-dyn-select-code'] as string,
lang: flowStore.val.schema['x-windmill-dyn-select-lang'] as ScriptLang
}
: undefined}
/>
</div>
{/key}
{/if}
</SchemaFormWithArgPicker>
</div>
{/if}
{/if}
<div class="pt-4 flex flex-col border-t relative">
{#if flowHasChanged()}
<div class="pb-2">
<div
class="text-xs bg-orange-100 text-orange-700 border border-orange-700/30 p-2 flex items-center gap-2 rounded"
>
<AlertTriangle size={12} /> Flow changed since last preview
<div class="flex"></div>
</div>
</div>
{/if}
<div class="w-full flex pb-2 items-start justify-between gap-4">
{#if job}
<JobDetailHeader {job} compact={true} />
{:else}
<div></div> <!-- empty div to keep the same layout -->
{/if}
<FlowHistoryJobPicker
selectInitial={jobId == undefined}
newFlow={$initialPathStore === ''}
on:select={(e) => {
if (!currentJobId) {
currentJobId = jobId
}
const detail = e.detail
jobId = detail.jobId
if (detail.initial && stepHistoryLoader?.flowJobInitial === undefined) {
stepHistoryLoader?.setFlowJobInitial(detail.initial)
}
}}
on:unselect={() => {
jobId = currentJobId
currentJobId = undefined
}}
path={$initialPathStore == '' ? $pathStore : $initialPathStore}
bind:loading={loadingHistory}
/>
</div>
{#if jobId}
<FlowProgressBar {job} bind:this={flowProgressBar} slim textPosition="bottom" showStepId />
{/if}
{#if job}
<div class="w-full my-6">
<FlowExecutionStatus
{job}
{isOwner}
innerModules={job?.flow_status?.modules}
{suspendStatus}
/>
</div>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
{#if jobId}
{#if stepHistoryLoader?.flowJobInitial}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={() => {
stepHistoryLoader?.setFlowJobInitial(false)
}}
class="cursor-pointer h-full hover:bg-gray-500/20 dark:hover:bg-gray-500/20 dark:bg-gray-500/80 rounded bg-gray-500/40 absolute top-0 left-0 w-full z-50"
>
<div class="text-center text-primary text-lg py-2 pt-20"
><span class="font-bold border p-2 bg-surface-secondary rounded-md"
>Previous run of this flow from history</span
></div
>
</div>
{/if}
<FlowStatusViewer
bind:job
bind:localModuleStates
bind:expandedSubflows={previewExpandedSubflows}
bind:localDurationStatuses
{suspendStatus}
hideDownloadInGraph={customUi?.downloadLogs === false}
wideResults
bind:flowState={flowStateStore.val}
workspaceId={opWs}
{jobId}
onDone={async ({ job: completedJob }) => {
isRunning = false
$executionCount = $executionCount + 1
// Reset 'initial' flags for modules that were part of this flow test,
// so OutputPicker no longer shows "Run loaded from history"
for (const mod of completedJob.flow_status?.modules ?? []) {
if (mod.id) {
stepHistoryLoader?.resetInitial(mod.id)
}
}
if (recordingMode && completedJob?.id) {
lastRecordingJobId = completedJob.id
}
onJobDone?.()
}}
bind:selectedJobStep
bind:rightColumnSelect
bind:isOwner
{render}
{customUi}
showLogsWithResult
notes={flowStore.val.value.notes}
groups={flowStore.val.value.groups}
/>
{:else if loadingHistory}
<div class="italic text-primary h-full grow mx-auto flex flex-row items-center gap-2">
<Loader2 class="animate-spin" /> <span> Loading history... </span>
</div>
{:else}
<div class="italic text-tertiary h-full grow mx-auto flex flex-row items-center">
Flow status will display here
</div>
{/if}
</div>
</div>
</div>