feat: redesign flow step, loop and branch settings panels (#10026)

* feat: responsive modal step panel for the flow editor in sessions

On narrow layouts the flow editor's step-details pane opens as a modal
(double-click a graph node) instead of a split pane, with a dock/float
toggle. Scoped to sessions via allowModalPanel; the full-page editor is
unchanged.

- FlowEditor: modal/docked modes gated by mount width + allowModalPanel,
  small header (step-id Badge + subtle dock/close), standing
  double-click hint, and a per-step hint in the name tooltip
- selectionManager: onSelectIntent hook so flow-level panels (settings,
  input, triggers…) open the modal on single click
- PropPickerWrapper: collapse the prop picker until connect and animate
  it in via AnimatedPane (runs-page pattern), no blue connect ring in
  modal mode
- StepInputGen: drop the TAB/Wand autocompletion button + spinner
  (feature still works via focus + Tab)
- InputTransformForm: decouple the Help dropdown from the AI suggestion
- FlowModuleHeader: move 'Save to workspace' into an ellipsis dropdown

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: loop editor rendering and nested splitpanes splitters in the sessions modal

- Loop iterator/parallelism: keep the picker split pane (forceExpanded) so
  the editor fills its box and the picker shows; the collapse-until-connect
  mode stays for the step inputs
- Remove the intrusive AI TAB/Wand autocompletion button from IteratorGen
  (generation still runs headless via focus + Tab)
- Size the iterator connect plug and restyle the loop header/labels/toggles
- Scope the global `.splitter-hidden` splitter-hiding rule to direct children
  so it no longer leaks into nested Splitpanes under the sessions preview

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: redesign flow step advanced settings as a single toggle-first column

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: taller step test pane by default and restyle advanced section titles

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: show flow run-settings params disabled when a setting is toggled off

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: single-column for-loop panel reusing the run-settings accordion

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: single-column while-loop panel reusing the run-settings accordion

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: single-column branch panels reusing the run-settings accordion

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: auto-open modal panel when creating an AI agent tool

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: redesign branch panels with card layout and shared predicate editor

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: remove per-setting status badges from flow map nodes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: sync package-lock after windmill-utils-internal bump

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: polish prop-picker plug button and branch panel layouts

* fix: persist skip-if-stopped toggles in early stop settings

* fix: open the step panel modal on demand and cap its width

* fix: restore graph step setting badges, strip panel header chips instead

* feat: docked panel header with detach action and open-details step menu

* feat: width-based panel mode on every surface with inline detach action

* refactor: single source for flow step settings and their defaults

* docs: pin flow editor vocabulary in CONTEXT.md

* fix: open the trigger panel on double click or a specific trigger

* fix: keep module pickers inside their pane and dismissable

* fix: drop the misleading chevron on the MCP tool entry

* fix: resolve flow approvals against the job's workspace, not the nav one

* refactor: derive the approval workspace from the job, not from callers

* fix: restore S3 snippets and gate params while their setting is off

* fix: restore branch mock controls and address review findings

* chore: drop stray debug log from the flow map item

* feat: pinned output section for loop and branch panels

* fix: open the panel for deliberate navigation from the flow header

* perf: mount branch predicate editors on demand

* fix: skip predicate picker previews the previous step's result

* fix: flow-level graph nodes open their panel on a single click

* fix: open the step panel for AI chat selections, not for undo

* chore: drop dead console.log and duplicated modalPanel doc

* fix: re-sync expression editors and scope error-handler settings

* fix: match the failure module exactly and ignore unselectable nodes

* fix: keep concurrency editable, honour module cache_ttl, tighten panel ids

* fix: open panel from indirect selections, use presence for value-driven toggles

* fix: don't open settings on error-handler delete, flush editors on unmount

* fix: guard editor destroy flush, keep retry kind reachable

* refactor: name the run settings panel after the domain vocabulary

* fix: only write editor flushes to the step they belong to

* fix: bind step panels by id so a delete can't retarget editor writes

* fix: don't let the trigger picker's escape close the drawer beneath it

* docs: condense two comments to the constraint they record

* fix: arbitrate escape through the overlay stack instead of deferring to it

* fix: key nested step blocks by identity so anchored bindings can't go stale

* fix: untrack the overlay-stack push and drop the frozen branch binding

* chore: state the escape rationale once, key branch lists, format

* fix: let the topmost overlay own escape instead of the graph

* fix: keep the dynamic-input help box out of static template fields

* fix: restore the graph connect on the for-loop iterator

* fix: end connect mode with the modal and keep it to docked panels

* fix: never enter graph connect mode from the modal panel

* fix: reveal inserted steps, restore editor pane size, unleak the drawer stack

* fix: keep the enable-AI popover reachable in session panes

* feat: add the connect policy and its single armed slot

* refactor: one picker for every expression input

* refactor: route every connect through one armed slot

* fix: give every connect button the same footprint

* fix: keep the connect ring from showing through the button

* fix: keep flow card actions right-aligned beside the detach button

* fix: give the connect ring an opaque ground to mask against

* feat: dock the panel back without reopening it

* feat: dock the panel from the graph control bar

* style: round the graph control bar and size its glyphs

* style: customize the graph controls through their supported api

* style: build the graph control bar from lucide icons

* fix: use the graph's tooltip component in the zoom controls

* style: pad the graph controls and enlarge their glyphs

* style: pad the graph controls and put dock at the bar's end

* refactor: give settings rows the same popover picker as other expressions

* fix: pass the wrapper's pickable properties to nested inputs

* refactor: stack step settings and render every expression through the step input form

* feat: split loop panels into tabs and rework the approval form

* feat: anchor drawers to their host pane and give them a size floor

* fix: mark the loop iterator expression as required

* refactor: badge ee-only toggles instead of a warning line

* fix: flag an empty loop iterator expression as an error

* refactor: pick the early-stop flow status from one toggle group

* fix: keep parallel loops uncapped unless a limit is opted into

* fix: scope the overlay stack to its host and disarm connect on dismissal

* fix: anchor the trigger picker to its host pane

* feat: move diff into the menu when the top bar is narrow

* fix: gate the result logs toggle to the graph popover

* feat: raise the modal-panel breakpoint to 1280

* fix: anchor flow editor popovers and fullscreen to their host pane

* fix: anchor overlays to their host pane and mute them when hidden

* fix: portal hosted modals and menus into the pane they anchor to

* fix: keep non-listening dialogs off the overlay stack

* fix: drop the topmost gate from confirmation dialogs

* fix: silence overlays in a collapsed preview panel

* feat: rework the branch panels with tabs, reordering and add/delete

* refactor: fold the detached-panel chrome into the card header

* fix: give every flow panel a titled card header

* fix: stop the step panel oscillating on an auto-height editor

* feat: consolidate script panel actions and restore branch predicate AI

* fix: restore the logs toggle on the flow result popover

* fix: collapse the idle property picker in modal step panels

* fix: stop the docked pane scrolling alongside its panel

* fix: space the last settings row off the panel bottom

* revert: always show the property picker pane in step panels

* chore: keep the inline script AI button identical to main

* fix: ask for AI input suggestions on click, not on hover

* fix: keep graph connects armed and remount the parallelism input

* style: reveal the predicate AI button on row hover

* style: give branch cards a handle and delete column

* refactor: arbitrate flow overlay escape through Disposable

* fix: give the popover picker its results and re-narrow the EE badge

* docs: correct loopSubset and guard the modal width measurement

* fix: insert picked properties at the cursor in expression inputs

* fix: give the expanded-subflow panel the shared header chrome

* style: rename the suspend setting to Suspend until approval/resume

* feat: open a step's modal when clicking the step already selected

* feat: add an auto/attached/detached toggle for the step panel

* refactor: pick the step panel's placement from one named menu

* refactor: keep the panel-mode module's exports to what is consumed

* feat: show each configured setting's value on its badge

* fix: carry the suspend rename into the step settings registry

* docs: name both gestures in the step explore hint

* test: pin where the step panel goes for a given width and preference

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-08-05 12:25:25 +02:00
committed by GitHub
parent 1dcb6bb900
commit 09c8f3b1f3
111 changed files with 5583 additions and 3997 deletions
+1
View File
@@ -23,6 +23,7 @@ Open-source platform for internal tools, workflows, API integrations, background
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy, `gpt-5.6-sol`, `xhigh` reasoning; requires the `codex` CLI >= 0.144.1.
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
- **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does.
- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
- **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise).
+38
View File
@@ -0,0 +1,38 @@
# Windmill
Open-source platform for internal tools, workflows, API integrations, background jobs and UIs. This file pins the vocabulary that is specific to Windmill's domain, so that code, docs and reviews name the same thing the same way.
## Language
### Flows
**Step**:
One node of a flow — the unit a user selects in the graph and configures in the right-hand panel. Typed as `FlowModule` in code.
_Avoid_: module (ambiguous with the architectural sense), node, action
**Step setting**:
A per-step runtime option stored on the step itself: retries, error handling, timeout, concurrency limit, priority, cache, debounce, early stop, skip, suspend, sleep, lifetime. Distinct from the step's inputs and its code. The panel that edits them is the **run settings** tab; a single setting is still a step setting.
_Avoid_: advanced setting, step config, flow option
**Configured**:
Said of a step setting whose config object is present on the step. Deliberately not the same as "would change the runtime's behaviour" — a setting can be configured and still be a no-op (`sleep` of `0`). Every surface that answers "is this setting on?" answers it this way.
_Avoid_: enabled, active, effective
**Trigger step**:
The first step of a polling flow. It runs on a schedule and returns the items found since its last run; an empty return means there is nothing to process and the flow stops early, marked skipped rather than failed.
_Avoid_: poll script, trigger node, schedule step
**Default predicate**:
The `stop_after_if` expression seeded onto a trigger step at creation, encoding what "nothing new" looks like. One value, owned in one place, shared by every path that creates a trigger step.
**Connect**:
Arming an input so that the next property picked fills it. A property can be picked from the prop picker or, when the panel is docked beside the graph, by clicking a step node's output. At most one input is armed per panel, so a pick always has exactly one destination.
_Avoid_: link, bind, plug (the icon is a plug; the action is connecting)
**Step input**:
One argument of a step, edited in the step's input form. Its prop picker is a pane beside the form, always visible, so previous results can be browsed without connecting.
_Avoid_: argument field, param
**Expression input**:
Any other place a property can be picked into: the loop iterator, skip and early-stop predicates, the retry condition, a branch predicate, timeout. Its prop picker opens in a popover from the connect button rather than taking a pane.
_Avoid_: JS field, code input
+2 -1
View File
@@ -719,7 +719,8 @@
onkeydown: () => (ignoreValueUndefined = true),
placeholder: placeholder ?? defaultValue ?? '',
min: extra['min'],
max: extra['max']
max: extra['max'],
step: extra['step']
}}
{error}
bind:value
@@ -932,7 +932,10 @@
{/if}
<style>
:global(.splitter-hidden .splitpanes__splitter) {
/* Direct child only: a descendant selector leaks into nested Splitpanes (e.g. the
sessions preview reuses `.splitter-hidden`, which would otherwise hide the flow
editor / modal splitters too). */
:global(.splitter-hidden > .splitpanes__splitter) {
background-color: transparent !important;
border: none !important;
opacity: 0 !important;
+7 -1
View File
@@ -1971,7 +1971,13 @@
})
onDestroy(() => {
console.log('destroying editor')
// Flush a pending keystroke debounce, or unmounting discards the last
// `changeTimeout` ms of typing. Both guards are load-bearing: without a pending
// timer Monaco isn't the newer side (an external write may be), and before init
// `getCode()` is '' — flushing either case overwrites real content.
if (editor && timeoutModel !== undefined) {
updateCode()
}
valueAfterDispose = getCode()
pasteCleanup?.()
destroyed = true
+42 -16
View File
@@ -199,6 +199,30 @@
// (session pane, drawer, etc.) where the viewport stays wide.
let topbarWidth = $state(0)
const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720)
const diffEnabled = $derived(customUi?.topBar?.diff != false)
// Nothing to compare against until a deployed version exists.
const diffDisabled = $derived(!savedFlow || newFlow || savedFlow?.no_deployed === true)
const diffTitle = $derived(
diffDisabled ? 'Deploy this flow once to compare against the deployed version' : 'Diff'
)
// The narrow bar (sessions) and the width-collapsed one have no room for a Diff
// button, so it moves into the menu ahead of Deployment History instead of
// dropping out of reach.
const diffInMenu = $derived(condensedHeader || compactTopbar)
const diffMenuItems: Item[] = $derived(
diffEnabled && diffInMenu
? [
{
displayName: 'Diff',
icon: DiffIcon,
action: () => openDiffDrawer(),
disabled: diffDisabled,
tooltip: diffDisabled ? diffTitle : undefined
}
]
: []
)
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
// AI changes warning modal
@@ -751,8 +775,10 @@
const stepsInputArgs = new StepsInputArgs()
// Every caller is a deliberate "show me that panel" action (a toolbar button, the
// preview's trigger shortcut), so the panel must open even in modal mode.
function select(selectedId: string) {
selectionManager.selectId(selectedId)
selectionManager.selectId(selectedId, { openPanel: true })
}
let insertButtonOpen = writable<boolean>(false)
@@ -863,7 +889,8 @@
}
}
}
selectionManager.selectId('Input')
// Undo restores a selection as a side effect; it is not a request to see Input.
selectionManager.selectId('Input', { openPanel: false })
}
function handleRedo() {
@@ -911,7 +938,9 @@
let ids = generateIds()
let idx = ids.indexOf(selectedIdStore!)
if (idx > -1 && idx < ids.length - 1) {
selectionManager.selectId(ids[idx + 1])
// Traversal, not a request to see any one panel: the ids list starts with
// flow-level entries, and opening the modal mid-walk swallows the arrows.
selectionManager.selectId(ids[idx + 1], { openPanel: false })
event.preventDefault()
}
}
@@ -922,7 +951,7 @@
let ids = generateIds()
let idx = ids.indexOf(selectedIdStore!)
if (idx > 0 && idx < ids.length) {
selectionManager.selectId(ids[idx - 1])
selectionManager.selectId(ids[idx - 1], { openPanel: false })
event.preventDefault()
}
}
@@ -945,7 +974,9 @@
onClick: () => void
}> = []
if (untrack(() => customUi).topBar?.extraDeployOptions != false) {
// In a session pane every one of these leaves the session (details page, new
// tab), so the deploy button carries no dropdown there — as in ScriptBuilder.
if (untrack(() => customUi).topBar?.extraDeployOptions != false && !inSessionPane) {
if (!newFlow) {
dropdownItems.push({
label: 'Exit & see details',
@@ -1028,15 +1059,16 @@
const mod = isMac() ? '⌘' : 'Ctrl+'
function getMoreItems(): Item[] {
const leadingItems = [...diffMenuItems, ...baseMenuItems]
return [
...baseMenuItems,
...leadingItems,
{
displayName: 'Undo',
icon: Undo,
action: () => handleUndo(),
disabled: $history.index === 0,
shortcut: `${mod}Z`,
separatorTop: baseMenuItems.length > 0
separatorTop: leadingItems.length > 0
},
{
displayName: 'Redo',
@@ -1369,13 +1401,7 @@
></span>
{/if}
</div>
{#if customUi?.topBar?.diff != false}
{@const isDraftOnly = savedFlow?.no_deployed === true}
{@const diffDisabled = !savedFlow || newFlow || isDraftOnly}
{@const diffTitle =
newFlow || isDraftOnly
? 'Deploy this flow once to compare against the deployed version'
: 'Diff'}
{#if diffEnabled && !diffInMenu}
<!-- A disabled <button> fires no pointer events, so a title/tooltip on
it never shows on hover. pointer-events-none on the button lets the
hover reach this titled wrapper instead. -->
@@ -1386,7 +1412,6 @@
on:click={() => openDiffDrawer()}
disabled={diffDisabled}
btnClasses={diffDisabled ? 'pointer-events-none' : undefined}
iconOnly={compactTopbar}
title={diffTitle}
startIcon={{ icon: DiffIcon }}
>
@@ -1443,6 +1468,7 @@
{disabledFlowInputs}
disableAi={disableAi || customUi?.stepInputs?.ai == false}
disableSettings={customUi?.settingsPanel === false}
modalPanel={customUi?.modalPanel != false}
{loading}
on:reload={() => {
renderCount += 1
@@ -1464,7 +1490,7 @@
{savedFlow}
onDeployTrigger={handleDeployTrigger}
onEditInput={(moduleId, key) => {
selectionManager.selectId(moduleId)
selectionManager.selectId(moduleId, { openPanel: true })
// Use new prop-based system
forceTestTab[moduleId] = true
highlightArg[moduleId] = key
@@ -5,25 +5,50 @@
import { twMerge } from 'tailwind-merge'
interface Props {
connecting: boolean;
id?: undefined | string;
wrapperClasses?: string;
connecting: boolean
id?: undefined | string
wrapperClasses?: string
disabled?: boolean
title?: string
}
let { connecting, id = undefined, wrapperClasses = '' }: Props = $props();
let {
connecting,
id = undefined,
wrapperClasses = '',
disabled = false,
title = undefined
}: Props = $props()
// The ring is masked by an ::after that resolves `background: inherit` up to whatever
// encloses the button. Give it an opaque ground while animating, or the gradient shows
// straight through. It can't go on the wrapper itself — the scoped `.gradient-button`
// rule outranks a utility class there.
const animating = $derived(connecting)
</script>
<AnimatedButton animate={connecting} baseRadius="6px" animationDuration="2s" marginWidth="2px">
<Button
variant="default"
btnClasses={twMerge(
connecting ? 'text-accent' : '',
'bg-surface hover:bg-surface-hover group/plug-btn overflow-clip flex p-0'
)}
on:click
{...id ? { id } : {}}
{wrapperClasses}
>
<Plug size={14} />
</Button>
</AnimatedButton>
<div class="flex {animating ? 'bg-surface rounded-md' : ''}">
<AnimatedButton animate={animating} baseRadius="6px" animationDuration="2s" marginWidth="2px">
<Button
variant="default"
btnClasses={twMerge(
connecting ? 'text-accent' : '',
'bg-surface hover:bg-surface-hover group/plug-btn overflow-clip flex p-0'
)}
on:click
{disabled}
{...title ? { title } : {}}
{...id ? { id } : {}}
wrapperClasses={twMerge(
// h-5 matches ButtonType.UnifiedHeightClasses.xs, the height of the
// static/expression switch it sits next to. Shrink by exactly the animated
// ring's margin so the footprint holds and nothing beside it shifts.
animating ? 'h-4 w-7' : 'h-5 w-8',
'p-0',
wrapperClasses
)}
>
<Plug size={12} />
</Button>
</AnimatedButton>
</div>
@@ -638,7 +638,6 @@
<div class="w-full my-6">
<FlowExecutionStatus
{job}
workspaceId={opWs}
{isOwner}
innerModules={job?.flow_status?.modules}
{suspendStatus}
@@ -1,7 +1,6 @@
<script lang="ts">
import { mergeSchema } from '$lib/common'
import { type Job, JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { ExternalLink, X } from 'lucide-svelte'
import DisplayResult from './DisplayResult.svelte'
@@ -14,6 +13,8 @@
interface Props {
isOwner: boolean
/** The workspace the job ran in — pass `job.workspace_id`, never the navigation
* workspace, which differs whenever the editor is embedded. */
workspaceId: string | undefined
job: Job
light?: boolean
@@ -39,12 +40,12 @@
if (jobId === lastJobId) {
return
}
if (!jobId) {
if (!jobId || !workspaceId) {
return {}
}
lastJobId = jobId
let job_result = (await JobService.getCompletedJobResult({
workspace: workspaceId ?? $workspaceStore ?? '',
workspace: workspaceId,
id: jobId
})) as any
const args = job_result?.default_args ?? {}
@@ -64,10 +65,14 @@
let loading = $state(false)
let actionTaken = $state(false)
async function continu(approve: boolean) {
if (!workspaceId) {
sendUserToast('Cannot resume: the job has no workspace', true)
return
}
loading = true
try {
await JobService.resumeSuspended({
workspace: workspaceId ?? $workspaceStore ?? '',
workspace: workspaceId,
jobId: job?.id ?? '',
requestBody: {
payload: approve ? (default_payload as any) : undefined,
@@ -87,6 +87,7 @@
{flowStateStore}
{disableAi}
{...props}
customUi={{ modalPanel: false, ...props.customUi }}
liveEditorDraftStoragePath={draftStoragePath || undefined}
/>
{/if}
@@ -18,12 +18,12 @@
<div>
<IconedResourceType width="20px" height="20px" name={path.split('/')[2]} silent={true} />
</div>
<span class="text-sm truncate">{path}</span>
<span class="truncate text-xs text-primary">{path}</span>
{:else}
<div class="center-center">
<Building size={16} />
</div>
<span class="text-sm truncate">{path}</span>
<span class="truncate text-xs text-primary">{path}</span>
{#if hash}
<Badge>{truncateHash(hash)}</Badge>
{/if}
@@ -11,7 +11,7 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import type { InputCat, DynamicInput as DynamicInputTypes } from '$lib/utils'
import { createEventDispatcher, getContext, onDestroy, untrack } from 'svelte'
import { createEventDispatcher, getContext, onDestroy, untrack, type Snippet } from 'svelte'
import { computeShow } from '$lib/utils'
import ArgInput from './ArgInput.svelte'
@@ -37,15 +37,33 @@
import type { PickableProperties } from './flows/previousResults'
import { twMerge } from 'tailwind-merge'
import FlowPlugConnect from './FlowPlugConnect.svelte'
import ExpressionPicker from './flows/propPicker/ExpressionPicker.svelte'
import { deepEqual } from 'fast-equals'
import S3ArrayHelperButton from './S3ArrayHelperButton.svelte'
import { inputBorderClass } from './text_input/TextInput.svelte'
import FakeMonacoPlaceHolder from './FakeMonacoPlaceHolder.svelte'
import { slideDynamic } from '$lib/transitions'
import InputError from './InputError.svelte'
interface Props {
schema: Schema | { properties?: Record<string, any>; required?: string[] }
arg: InputTransform | any
argName: string
/** Display name, when the schema key isn't what the user should read. */
label?: string
/** Replaces the label header, so a setting's own toggle can name the field. */
header?: Snippet
/** The kind this field always holds, for a value that doesn't carry a `type` of its
* own — a flow predicate is stored as a bare `{ expr }`. */
argType?: InputTransform['type']
/** Keep only the header: the setting owning this field is switched off, so there is
* no value yet. Rendering the header here (rather than swapping in a bare toggle
* outside) keeps one persistent row, so the field can slide in and out under it. */
collapsed?: boolean
/** Slide the input in and out as `collapsed` flips. */
animateAppear?: boolean
/** Message shown under the field, which also turns its border red. */
error?: string | undefined
headerTooltip?: string | undefined
headerTooltipIconClass?: string
HeaderTooltipIcon?: any
@@ -55,7 +73,16 @@
pickForField?: string | undefined
variableEditor?: VariableEditor | undefined
itemPicker?: ItemPicker | undefined
/** Hide the static/expression switch, for a field that only ever holds one kind.
* The connect button and the AI helper stay. */
noDynamicToggle?: boolean
/** Replaces the default StepInputGen, for a field with its own AI helper. That
* helper drives `suggestion` (its ghost text) and `aiOnKeyUp` (Tab to accept),
* which the built-in one reaches through `stepInputGen` instead. */
aiGen?: Snippet
suggestion?: string
focused?: boolean
aiOnKeyUp?: (e: KeyboardEvent) => void
argExtra?: Record<string, any>
pickableProperties?: PickableProperties | undefined
enableAi?: boolean
@@ -74,6 +101,12 @@
schema = $bindable(),
arg = $bindable(),
argName = $bindable(),
label = undefined,
header = undefined,
argType = undefined,
collapsed = false,
animateAppear = false,
error = undefined,
headerTooltip = undefined,
headerTooltipIconClass = '',
HeaderTooltipIcon = InfoIcon,
@@ -84,6 +117,10 @@
variableEditor = undefined,
itemPicker = undefined,
noDynamicToggle = false,
aiGen = undefined,
suggestion = $bindable(),
focused = $bindable(),
aiOnKeyUp = undefined,
argExtra = {},
pickableProperties = undefined,
enableAi = false,
@@ -119,6 +156,11 @@
const propPickerWrapperContext: PropPickerWrapperContext | undefined =
getContext<PropPickerWrapperContext>('PropPickerWrapper')
const pickerMode = $derived(propPickerWrapperContext?.pickerMode?.() ?? 'pane')
// Settings rows hand their properties to the wrapper, not to this form.
const connectableProperties = $derived(
pickableProperties ?? propPickerWrapperContext?.pickableProperties?.()
)
const {
inputMatches,
connectProp: focusProp,
@@ -142,7 +184,11 @@
allowedAiTransforms === undefined || allowedAiTransforms.includes(argName)
)
let propertyType = $state(getPropertyType(arg))
// `argType` wins over whatever the value carries: a predicate has no `type` field, so
// inferring would land it on the static input instead of the expression editor.
const argKind = $derived(argType ?? arg?.type)
// Seeded once: `propertyType` is what the user switches, and `argType` is fixed per field.
let propertyType = $state(untrack(() => argType) ?? getPropertyType(arg))
function setExpr() {
const newArg = $exprsToSet?.[argName]
@@ -266,7 +312,7 @@
) {
setJavaScriptExpr(arg.value)
} else {
stepInputGen?.onKeyUp?.(e)
;(aiOnKeyUp ?? stepInputGen?.onKeyUp)?.(e)
}
}
@@ -461,7 +507,6 @@
resourceTypes = await getResourceTypes()
}
let focused = $state(false)
let stepInputGen: StepInputGen | undefined = $state(undefined)
loadResourceTypes()
@@ -497,8 +542,6 @@
['s3object', 's3_object'].includes(schema?.properties?.[argName]?.items?.resourceType)
)
let suggestion: string | undefined = $state()
// Svelte bug ...
// Somehow the value is updated in the UI of the parent, but not in the children
// when passed as a prop. setTimeout is a workaround to force the update
@@ -509,85 +552,111 @@
})
</script>
{#if arg != undefined && !hidden}
{#if (arg != undefined || collapsed) && !hidden}
<div class={twMerge('relative group flex flex-col gap-1', className)}>
<div class="flex flex-row flex-wrap justify-between gap-1">
<div class="flex grow min-h-7 items-end">
<FieldHeader
label={argName}
simpleTooltip={headerTooltip}
simpleTooltipIconClass={headerTooltipIconClass}
SimpleTooltipIcon={HeaderTooltipIcon}
format={schema?.properties?.[argName]?.format}
contentEncoding={schema?.properties?.[argName]?.contentEncoding}
required={schema.required?.includes(argName)}
type={schema.properties?.[argName]?.type}
/>
<!-- `relative` so the absolute button cluster below anchors to this row rather than
to the whole field, letting it share the label's baseline. -->
<div class="relative flex flex-row flex-wrap justify-between gap-1">
<!-- min-h-7 reserves room for the button cluster beside a plain label; a custom
header is a control of its own and sets the row's height itself. -->
<div class="flex grow items-end {header ? '' : 'min-h-7'}">
{#if header}
{@render header()}
{:else}
<FieldHeader
label={label ?? argName}
simpleTooltip={headerTooltip}
simpleTooltipIconClass={headerTooltipIconClass}
SimpleTooltipIcon={HeaderTooltipIcon}
format={schema?.properties?.[argName]?.format}
contentEncoding={schema?.properties?.[argName]?.contentEncoding}
required={schema.required?.includes(argName)}
type={schema.properties?.[argName]?.type}
/>
{#if isStaticTemplate(inputCat)}
<div>
<span
class="border text-gray-400 dark:text-gray-500 text-2xs font-medium mr-2 px-1 !py-[1px] rounded ml-2.5 {propertyType ==
'static' && arg.type === 'javascript'
? 'visible'
: 'invisible'}"
>
{'${...}'}
</span>
</div>
{#if isStaticTemplate(inputCat)}
<div>
<span
class="border text-gray-400 dark:text-gray-500 text-2xs font-medium mr-2 px-1 !py-[1px] rounded ml-2.5 {propertyType ==
'static' && arg?.type === 'javascript'
? 'visible'
: 'invisible'}"
>
{'${...}'}
</span>
</div>
{/if}
{/if}
</div>
{#if !noDynamicToggle}
<div
class="flex flex-row gap-x-2 z-10 absolute right-0 group-hover:bg-surface transition-colors"
>
{#if enableAi}
<StepInputGen
bind:this={stepInputGen}
{focused}
{arg}
schemaProperty={schema?.properties?.[argName]}
on:showExpr={(e) => (suggestion = e.detail || undefined)}
on:setExpr={(e) => {
arg = { type: 'javascript', expr: e.detail }
propertyType = 'javascript'
monaco?.setCode('')
monaco?.insertAtCursor(e.detail)
}}
{pickableProperties}
{argName}
btnClass={twMerge(
'h-7 min-w-8 px-2',
'group-hover:opacity-100 transition-opacity',
!connecting ? 'opacity-0' : ''
)}
/>
{/if}
<!-- Nothing to connect to or switch while collapsed: there is no value yet. -->
<div
class="flex flex-row items-end gap-x-2 z-10 absolute right-0 bottom-0 group-hover:bg-surface transition-colors {collapsed
? 'hidden'
: ''}"
>
{#if aiGen}
{@render aiGen()}
{:else if enableAi}
<StepInputGen
bind:this={stepInputGen}
{focused}
schemaProperty={schema?.properties?.[argName]}
on:showExpr={(e) => (suggestion = e.detail || undefined)}
on:setExpr={(e) => {
arg = { type: 'javascript', expr: e.detail }
propertyType = 'javascript'
monaco?.setCode('')
monaco?.insertAtCursor(e.detail)
}}
{pickableProperties}
{argName}
/>
{/if}
{#if propPickerWrapperContext}
<FlowPlugConnect
wrapperClasses={twMerge(
connecting ? 'h-6 w-7' : 'h-7 w-8',
'group-hover:opacity-100 transition-opacity p-0',
!connecting ? 'opacity-0' : ''
)}
id="flow-editor-plug"
{connecting}
on:click={() => {
if ($propPickerConfig?.propName == argName) {
clearFocus()
} else {
focusProp?.(argName, (path) => {
connectProperty(path)
dispatch('change', { argName })
return true
})
}
}}
/>
{/if}
{#if propPickerWrapperContext && pickerMode === 'popover'}
<!-- Settings rows have no picker pane, so the properties hang off the
button itself, exactly as the other expression inputs do. -->
<ExpressionPicker
id={argName}
pickableProperties={connectableProperties}
result={propPickerWrapperContext.result?.()}
extraResults={propPickerWrapperContext.extraResults?.()}
onSelect={(path) => {
// A predicate is usually half-written when you reach for a property, so
// insert at the cursor and leave the rest of the expression alone. Only
// a field that isn't an expression yet gets replaced outright.
if (propertyType === 'javascript' && monaco) {
propPickerWrapperContext.onPick?.(path)
} else {
connectProperty(path)
}
dispatch('change', { argName })
}}
/>
{:else if propPickerWrapperContext}
<FlowPlugConnect
wrapperClasses={twMerge(
'group-hover:opacity-100 transition-opacity',
!connecting ? 'opacity-0' : ''
)}
id="flow-editor-plug"
{connecting}
on:click={() => {
if ($propPickerConfig?.propName == argName) {
clearFocus()
} else {
focusProp?.(argName, (path) => {
connectProperty(path)
dispatch('change', { argName })
return true
})
}
}}
/>
{/if}
<div class="{ButtonType.UnifiedHeightClasses.sm} relative">
{#if !noDynamicToggle}
<div class="{ButtonType.UnifiedHeightClasses.xs} relative">
<ToggleButtonGroup
selected={visiblePropertyType}
class="h-full"
@@ -708,215 +777,234 @@
{/snippet}
</ToggleButtonGroup>
</div>
</div>
{/if}
{/if}
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="relative w-full" onkeyup={handleKeyUp}>
<!-- {inputCat}
{#if !collapsed}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- A custom header means a setting's toggle owns this field, so the input is
indented under the toggle's label: `xs` switch (w-7) plus its ml-2. -->
<div
class="relative w-full {header ? 'pl-9' : ''}"
onkeyup={handleKeyUp}
transition:slideDynamic|global={{ duration: animateAppear ? 150 : 0 }}
>
<!-- {inputCat}
{propertyType} -->
<div class="relative flex flex-row items-top gap-1 justify-between">
<div class="min-w-0 grow">
{#if suggestion}
<div
class={`bg-surface-input rounded-md pl-2 overflow-auto ${inputBorderClass({ forceFocus: true })}`}
>
<FakeMonacoPlaceHolder autoheight code={suggestion} fontSize={12} />
</div>
{/if}
<div
class={suggestion ? 'opacity-0 absolute' : ''}
onkeydowncapture={(e) => {
if (e.key === 'Tab' && suggestion) {
e.preventDefault()
}
}}
>
{@render innerInput()}
</div>
{#snippet innerInput()}
{#if propertyType === 'ai'}
<div
class="text-sm text-tertiary italic p-3 bg-surface-secondary rounded-md border border-gray-200"
>
<span class="flex items-center gap-2 text-xs">
<InfoIcon size={13} />
This field will be filled by the AI agent dynamically
</span>
</div>
{#if argName && schema?.properties?.[argName]?.description}
<div class="text-xs italic py-1 text-hint">
<pre class="font-main whitespace-normal">
{schema.properties[argName].description}
</pre>
<div class="relative flex flex-row items-top gap-1 justify-between">
<div class="min-w-0 grow">
<!-- The ghost text covers the input and only the input: it has to stay mounted
(Monaco, focus, Tab) and keep its height, or what follows slides up
underneath it — and the overlay must not reach the rows below, which is
what would bury the Help dropdown. -->
<div class="relative">
{#if suggestion}
<div
class={`absolute inset-0 z-10 bg-surface-input rounded-md pl-2 overflow-auto ${inputBorderClass({ forceFocus: true })}`}
>
<FakeMonacoPlaceHolder autoheight code={suggestion} fontSize={12} />
</div>
{/if}
{:else if isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle}
<div class="flex flex-col gap-1">
<div
class={suggestion ? 'opacity-0' : ''}
onkeydowncapture={(e) => {
if (e.key === 'Tab' && suggestion) {
e.preventDefault()
}
}}
>
{@render innerInput()}
</div>
</div>
<InputError {error} />
<!-- Rendered outside the `suggestion ? opacity-0` wrapper so the AI
step-input autocompletion (ghost text, accepted with Tab) doesn't
hide the Help dropdown — the two stay independent. -->
{#if !hideHelpButton && propertyType === 'javascript' && argKind === 'javascript' && arg.expr != undefined}
<DynamicInputHelpBox />
{/if}
{#snippet innerInput()}
{#if propertyType === 'ai'}
<div
class="text-sm text-tertiary italic p-3 bg-surface-secondary rounded-md border border-gray-200"
>
<span class="flex items-center gap-2 text-xs">
<InfoIcon size={13} />
This field will be filled by the AI agent dynamically
</span>
</div>
{#if argName && schema?.properties?.[argName]?.description}
<div class="text-xs text-secondary">
<div class="text-xs italic py-1 text-hint">
<pre class="font-main whitespace-normal">
{schema.properties[argName].description}
</pre>
</pre>
</div>
{/if}
{:else if isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle}
<div class="flex flex-col gap-1">
{#if argName && schema?.properties?.[argName]?.description}
<div class="text-xs text-secondary">
<pre class="font-main whitespace-normal">
{schema.properties[argName].description}
</pre>
</div>
{/if}
{#if arg}
<TemplateEditor
bind:this={monacoTemplate}
{extraLib}
on:focus={onFocus}
on:blur={() => {
focused = false
}}
bind:code={arg.value}
fontSize={12}
on:change={() => {
dispatch('change', { argName, arg })
}}
loadAsync
class="bg-surface-input"
/>
{/if}
</div>
{:else if (propertyType === undefined || propertyType == 'static') && schema?.properties?.[argName]}
<ArgInput
{resourceTypes}
noMargin
compact
on:focus={onFocus}
on:blur={() => {
focused = false
}}
shouldDispatchChanges
on:change={() => {
dispatch('change', { argName, arg })
}}
label={argName}
bind:editor={monaco}
bind:description={schema.properties[argName].description}
bind:value={arg.value}
type={schema.properties[argName].type}
oneOf={schema.properties[argName].oneOf}
required={schema.required?.includes(argName)}
bind:pattern={schema.properties[argName].pattern}
bind:valid={inputCheck}
defaultValue={schema.properties[argName].default}
bind:enum_={schema.properties[argName].enum}
bind:format={schema.properties[argName].format}
contentEncoding={schema.properties[argName].contentEncoding}
bind:itemsType={schema.properties[argName].items}
properties={schema.properties[argName].properties}
nestedRequired={schema.properties[argName].required}
displayHeader={false}
extra={argExtra}
{variableEditor}
{itemPicker}
bind:pickForField
showSchemaExplorer
nullable={schema.properties[argName].nullable}
bind:title={schema.properties[argName].title}
bind:placeholder={schema.properties[argName].placeholder}
{helperScript}
{s3StorageConfigured}
{chatInputEnabled}
otherArgs={Object.fromEntries(
Object.entries(otherArgs).map(([key, transform]) => [
key,
transform?.type === 'static'
? transform.value
: transform?.type === 'javascript'
? transform.expr
: undefined
])
)}
>
{#snippet innerBottomSnippet()}
{#if shouldShowS3ArrayHelper}
<S3ArrayHelperButton
{connecting}
onClick={() =>
switchToJsAndConnect((path) =>
appendPathToArrayExpr(arg?.type === 'javascript' ? arg.expr : '', path)
)}
{#if arg}
<TemplateEditor
bind:this={monacoTemplate}
{extraLib}
on:focus={onFocus}
on:blur={() => {
focused = false
}}
bind:code={arg.value}
fontSize={12}
on:change={() => {
dispatch('change', { argName, arg })
}}
loadAsync
class="bg-surface-input"
/>
{/if}
{/snippet}
</ArgInput>
{:else if arg?.type === 'javascript' && arg.expr != undefined}
<div
class={`bg-surface-input rounded-md flex flex-col pl-2 overflow-auto ${inputBorderClass({ forceFocus: focused })}`}
>
<SimpleEditor
small
bind:this={monaco}
bind:code={arg.expr}
{extraLib}
lang="javascript"
shouldBindKey={false}
renderLineHighlight="none"
hideLineNumbers
on:focus={() => {
focused = true
updatePropsBeingEdited(true)
}}
</div>
{:else if (propertyType === undefined || propertyType == 'static') && schema?.properties?.[argName]}
<ArgInput
{resourceTypes}
noMargin
compact
on:focus={onFocus}
on:blur={() => {
focused = false
updatePropsBeingEdited(false)
}}
shouldDispatchChanges
on:change={() => {
dispatch('change', { argName, arg })
}}
autoHeight
loadAsync
/>
<!-- <input type="text" bind:value={arg.expr} /> -->
</div>
{#if shouldShowS3ArrayHelper}
<S3ArrayHelperButton
class="mt-2"
{connecting}
onClick={() =>
focusProp?.(argName, (path) => {
appendPathToArrayExpr(arg.expr, path)
return true
})}
/>
{/if}
{#if argName && schema?.properties?.[argName]?.description}
<div class="text-xs italic py-1 text-secondary">
<pre class="font-main whitespace-normal"
>{schema.properties[argName].description}</pre
>
label={argName}
bind:editor={monaco}
bind:description={schema.properties[argName].description}
bind:value={arg.value}
type={schema.properties[argName].type}
oneOf={schema.properties[argName].oneOf}
required={schema.required?.includes(argName)}
bind:pattern={schema.properties[argName].pattern}
bind:valid={inputCheck}
defaultValue={schema.properties[argName].default}
bind:enum_={schema.properties[argName].enum}
bind:format={schema.properties[argName].format}
contentEncoding={schema.properties[argName].contentEncoding}
bind:itemsType={schema.properties[argName].items}
properties={schema.properties[argName].properties}
nestedRequired={schema.properties[argName].required}
displayHeader={false}
extra={argExtra}
{variableEditor}
{itemPicker}
bind:pickForField
showSchemaExplorer
nullable={schema.properties[argName].nullable}
bind:title={schema.properties[argName].title}
bind:placeholder={schema.properties[argName].placeholder}
{helperScript}
{s3StorageConfigured}
{chatInputEnabled}
otherArgs={Object.fromEntries(
Object.entries(otherArgs).map(([key, transform]) => [
key,
transform?.type === 'static'
? transform.value
: transform?.type === 'javascript'
? transform.expr
: undefined
])
)}
>
{#snippet innerBottomSnippet()}
{#if shouldShowS3ArrayHelper}
<S3ArrayHelperButton
{connecting}
onClick={() =>
switchToJsAndConnect((path) =>
appendPathToArrayExpr(arg?.type === 'javascript' ? arg.expr : '', path)
)}
/>
{/if}
{/snippet}
</ArgInput>
{:else if argKind === 'javascript' && arg.expr != undefined}
<div
class={`bg-surface-input rounded-md flex flex-col pl-2 overflow-auto ${inputBorderClass({ forceFocus: focused, error: !!error })}`}
>
<SimpleEditor
small
bind:this={monaco}
bind:code={arg.expr}
{extraLib}
lang="javascript"
shouldBindKey={false}
renderLineHighlight="none"
hideLineNumbers
on:focus={() => {
focused = true
updatePropsBeingEdited(true)
}}
on:blur={() => {
focused = false
updatePropsBeingEdited(false)
}}
on:change={() => {
dispatch('change', { argName, arg })
}}
autoHeight
loadAsync
/>
<!-- <input type="text" bind:value={arg.expr} /> -->
</div>
{/if}
{#if !hideHelpButton}
<DynamicInputHelpBox />
{/if}
{#if shouldShowS3ArrayHelper}
<S3ArrayHelperButton
class="mt-2"
{connecting}
onClick={() =>
focusProp?.(argName, (path) => {
appendPathToArrayExpr(arg.expr, path)
return true
})}
/>
{/if}
<div class="mb-2"></div>
{:else}
<span class="text-xs text-red-500">
Not recognized input type {argName} ({arg.expr}, {propertyType})
</span>
<div class="flex mt-2">
<Button
variant="default"
size="xs"
on:click={() => {
arg.expr = ''
}}>Set expr to empty string</Button
></div
>
{/if}
{/snippet}
{#if argName && schema?.properties?.[argName]?.description}
<div class="text-xs italic py-1 text-secondary">
<pre class="font-main whitespace-normal"
>{schema.properties[argName].description}</pre
>
</div>
{/if}
<div class="mb-2"></div>
{:else}
<span class="text-xs text-red-500">
Not recognized input type {argName} ({arg.expr}, {propertyType})
</span>
<div class="flex mt-2">
<Button
variant="default"
size="xs"
on:click={() => {
arg.expr = ''
}}>Set expr to empty string</Button
></div
>
{/if}
{/snippet}
</div>
</div>
</div>
</div>
{/if}
</div>
{/if}
@@ -196,7 +196,10 @@
</div>
<style>
:global(.splitter-hidden .splitpanes__splitter) {
/* Direct child only: a descendant selector leaks into nested Splitpanes (e.g. the
sessions preview reuses `.splitter-hidden`, which would otherwise hide the flow
editor / modal splitters too). */
:global(.splitter-hidden > .splitpanes__splitter) {
background-color: transparent !important;
border: none !important;
opacity: 0 !important;
@@ -214,7 +214,7 @@
{/if}
</Section>
<Section label="Delete after completion">
<Section label="Delete after completion" eeOnly>
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/script_editor/settings#delete-after-use"
@@ -223,9 +223,6 @@
the specified delay once it is complete. Set to 0 for immediate deletion. The deletion is
irreversible. This settings ONLY applies when the script is used within a flow or triggered
synchronously.
{#if !$enterpriseLicense}
This option is only available on Windmill Enterprise Edition.
{/if}
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
@@ -650,8 +650,12 @@
onDestroy(() => {
try {
valueAfterDispose = getCode()
// Same guards as Editor: only a pending keystroke debounce is ours to flush.
if (editor && changeTimeoutId !== undefined) {
updateCode()
}
cancelPendingChanges()
valueAfterDispose = getCode()
pasteListenerCleanup?.()
vimDisposable?.dispose()
model && model.dispose()
+111 -91
View File
@@ -6,9 +6,9 @@
import { createEventDispatcher, untrack } from 'svelte'
import { twMerge } from 'tailwind-merge'
import Tooltip from './Tooltip.svelte'
import { AlertTriangle } from 'lucide-svelte'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import { inputBorderClass } from './text_input/TextInput.svelte'
import EEOnly from './EEOnly.svelte'
interface Props {
options?: {
@@ -55,99 +55,119 @@
const dispatch = createEventDispatcher<{ change: boolean }>()
const bothOptions = Boolean(untrack(() => options).left) && Boolean(untrack(() => options).right)
// Same badge the labelled containers (Label, Section, Subsection) show. Gated on
// `disabled` rather than on the license: a caller can leave an EE-only toggle
// interactive on CE precisely because it is already on, and "EE only" beside a control
// that plainly works reads as a lie.
const showEeBadge = $derived(eeOnly && disabled)
</script>
<label
for={id}
class="{className || ''} z-auto flex flex-row items-center duration-50 {disabled
? 'grayscale opacity-50 cursor-not-allowed'
: 'cursor-pointer'}"
title={options?.title}
>
{#if Boolean(options?.left)}
<span
class={twMerge(
'mr-2 font-normal duration-50 select-none',
bothOptions || textDisabled ? (checked ? 'text-disabled' : 'text-primary') : 'text-primary',
size === '2xs' ? 'text-2xs' : 'text-xs',
textClass
)}
style={textStyle}
>
{options?.left}
{#if options?.leftTooltip}
<Tooltip light={lightMode}>{options?.leftTooltip}</Tooltip>
{/if}
</span>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative"
onclick={stopPropagation(bubble('click'))}
use:triggerableByAI={{
id: aiId,
description: aiDescription,
callback: () => {
checked = !checked
}
}}
{#snippet control()}
<label
for={id}
class="{className || ''} z-auto flex flex-row items-center duration-50 {disabled
? 'grayscale opacity-50 cursor-not-allowed'
: 'cursor-pointer'}"
title={options?.title}
>
<input
onfocus={bubble('focus')}
onclick={bubble('click')}
{disabled}
type="checkbox"
{id}
class="sr-only peer"
bind:checked
onchange={stopPropagation((e) => {
dispatch('change', !!checked)
})}
/>
{#if Boolean(options?.left)}
<span
class={twMerge(
'mr-2 font-normal duration-50 select-none',
bothOptions || textDisabled
? checked
? 'text-disabled'
: 'text-primary'
: 'text-primary',
size === '2xs' ? 'text-2xs' : 'text-xs',
textClass
)}
style={textStyle}
>
{options?.left}
{#if options?.leftTooltip}
<Tooltip light={lightMode}>{options?.leftTooltip}</Tooltip>
{/if}
</span>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
"transition-all bg-surface-sunken rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
color == 'red'
? 'peer-checked:bg-red-600'
: color == 'blue'
? 'peer-checked:bg-blue-400 '
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-900',
size === 'md'
? 'w-11 h-6 after:top-0.5 after:left-[2px] after:h-5 after:w-5'
: size === 'sm'
? 'w-9 h-5 after:top-0.5 after:left-[2px] after:h-4 after:w-4'
: size === '2xs'
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3',
inputBorderClass()
)}
></div>
</div>
{#if Boolean(options?.right)}
<span
class={twMerge(
'ml-2 font-normal duration-50 select-none',
bothOptions || textDisabled ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary',
size === '2xs' ? 'text-2xs' : 'text-xs',
textClass
)}
style={textStyle}
class="relative"
onclick={stopPropagation(bubble('click'))}
use:triggerableByAI={{
id: aiId,
description: aiDescription,
callback: () => {
checked = !checked
}
}}
>
{options?.right}
{#if options?.rightTooltip}
<Tooltip documentationLink={options.rightDocumentationLink}>
{options.rightTooltip}
</Tooltip>
{/if}
</span>
{/if}
{@render right?.()}
</label>
{#if eeOnly && disabled}
<span class="inline-flex text-xs items-center gap-1 !text-yellow-500 whitespace-nowrap ml-8">
<AlertTriangle size={16} />
EE only <Tooltip>Enterprise Edition only feature</Tooltip>
</span>
<input
onfocus={bubble('focus')}
onclick={bubble('click')}
{disabled}
type="checkbox"
{id}
class="sr-only peer"
bind:checked
onchange={stopPropagation((e) => {
dispatch('change', !!checked)
})}
/>
<div
class={classNames(
"transition-all bg-surface-sunken rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:bg-surface after:border-white after:border after:rounded-full after:transition-all items-center",
color == 'red'
? 'peer-checked:bg-red-600'
: color == 'blue'
? 'peer-checked:bg-blue-400 '
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-900',
size === 'md'
? 'w-11 h-6 after:top-0.5 after:left-[2px] after:h-5 after:w-5'
: size === 'sm'
? 'w-9 h-5 after:top-0.5 after:left-[2px] after:h-4 after:w-4'
: size === '2xs'
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3',
inputBorderClass()
)}
></div>
</div>
{#if Boolean(options?.right)}
<span
class={twMerge(
'ml-2 font-normal duration-50 select-none',
bothOptions || textDisabled
? checked
? 'text-primary'
: 'text-disabled'
: 'text-primary',
size === '2xs' ? 'text-2xs' : 'text-xs',
textClass
)}
style={textStyle}
>
{options?.right}
{#if options?.rightTooltip}
<Tooltip documentationLink={options.rightDocumentationLink}>
{options.rightTooltip}
</Tooltip>
{/if}
</span>
{/if}
{@render right?.()}
</label>
{/snippet}
{#if showEeBadge}
<!-- The badge sits outside the label: a disabled one is greyed out and half
transparent, which would swallow it. -->
<div class="flex flex-row items-center gap-2">
{@render control()}
<EEOnly />
</div>
{:else}
{@render control()}
{/if}
@@ -1,10 +1,7 @@
<script lang="ts">
import { Check, Loader2, Wand2 } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { getNonStreamingMetadataCompletion } from './lib'
import { sendUserToast } from '$lib/toast'
import type { Flow, InputTransform } from '$lib/gen'
import ManualPopover from '../ManualPopover.svelte'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
import type { PickableProperties } from '../flows/previousResults'
@@ -14,7 +11,6 @@
import { yamlStringifyExceptKeys } from './utils'
import { stepInputCompletionEnabled } from '$lib/stores'
import { copilotInfo } from '$lib/aiStore'
import { twMerge } from 'tailwind-merge'
let generatedContent = $state('')
let loading = $state(false)
@@ -26,8 +22,6 @@
let { focused = false, arg, pickableProperties = undefined }: Props = $props()
let btnFocused = $state(false)
let empty = $derived(
Object.keys(arg ?? {}).length === 0 ||
(arg.type === 'static' && !arg.value) ||
@@ -111,8 +105,7 @@ Only output the expression, do not explain or discuss.`
function cancelOnOutOfFocus() {
setTimeout(() => {
if (!focused && !btnFocused) {
// only cancel if out of focus is not due to click on btn
if (!focused) {
cancel()
}
}, 150)
@@ -142,60 +135,7 @@ Only output the expression, do not explain or discuss.`
$effect(() => {
dispatch('showExpr', generatedContent)
})
let out = $state(true) // hack to prevent regenerating answer when accepting the answer due to mouseenter on new icon
</script>
{#if $copilotInfo.enabled && $stepInputCompletionEnabled}
<ManualPopover showTooltip={!empty && generatedContent.length > 0} placement="bottom" class="p-2">
<Button
size="xs"
color="light"
btnClasses={twMerge(
'text-ai bg-violet-100 dark:bg-gray-700 dark:hover:bg-surface-hover',
!loading && generatedContent.length > 0
? 'bg-green-100 text-green-800 hover:bg-green-100 dark:text-green-400 dark:bg-green-700 dark:hover:bg-green-700'
: ''
)}
on:click={() => {
if (!loading && generatedContent.length > 0) {
dispatch('setExpr', generatedContent)
generatedContent = ''
}
}}
on:focus={() => {
btnFocused = true
}}
on:blur={() => {
btnFocused = false
}}
on:mouseenter={(ev) => {
if (out) {
out = false
generateIteratorExpr()
}
}}
on:mouseleave={() => {
out = true
cancel()
}}
endIcon={{
icon: loading ? Loader2 : generatedContent.length > 0 ? Check : Wand2,
classes: loading ? 'animate-spin' : ''
}}
>
{#if focused}
{#if loading}
ESC
{:else if generatedContent.length > 0}
TAB
{/if}
{/if}
</Button>
{#snippet content()}
<div class="text-sm text-primary">
{generatedContent}
</div>
{/snippet}
</ManualPopover>
{/if}
<!-- Headless: the focus effect generates the ghost-text suggestion and
`onKeyUp` accepts it with Tab. There is no on-screen trigger. -->
@@ -14,6 +14,8 @@
import Popover from '$lib/components/meltComponents/Popover.svelte'
import type { Flow } from '$lib/gen'
import { copilotInfo } from '$lib/aiStore'
import { AIBtnClasses } from './chat/AIButtonStyle'
import { twMerge } from 'tailwind-merge'
let loading = $state(false)
interface Props {
@@ -94,14 +96,23 @@ Only return the expression without any wrapper. Do not explain or discuss.`
contentClasses="p-4 flex w-96"
>
{#snippet trigger()}
<!-- Sized to match FlowPlugConnect: the two sit side by side under every
predicate input, so they have to read as one pair of controls. -->
<Button
variant="default"
size="xs3"
color={loading ? 'red' : 'light'}
size="xs"
nonCaptureEvent={!loading}
startIcon={{ icon: Wand2 }}
iconOnly
title="AI Assistant"
btnClasses="min-h-[30px] text-ai bg-violet-100 dark:bg-gray-700"
btnClasses={twMerge(AIBtnClasses(), 'bg-surface overflow-clip flex p-0')}
wrapperClasses={twMerge(
// Revealed by the row it sits in, like the connect plug beside it. A request in
// flight keeps it visible so its cancel affordance stays reachable.
'h-5 w-8 p-0 group-hover:opacity-100 transition-opacity',
loading ? '' : 'opacity-0'
)}
{loading}
clickableWhileLoading
on:click={loading ? () => abortController?.abort() : () => {}}
@@ -121,11 +132,11 @@ Only return the expression without any wrapper. Do not explain or discuss.`
}}
/>
<Button
size="xs"
unifiedSize="sm"
color="light"
variant="contained"
buttonType="button"
btnClasses="!p-1 !w-[38px] !ml-2 text-ai bg-violet-100 dark:bg-gray-700"
btnClasses={'!ml-2 ' + AIBtnClasses()}
title="Generate predicate from prompt"
aria-label="Generate"
iconOnly
@@ -1,12 +1,8 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { Check, Loader2, Wand2 } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import { getNonStreamingMetadataCompletion } from './lib'
import { sendUserToast } from '$lib/toast'
import type { Flow, InputTransform } from '$lib/gen'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import type { Flow } from '$lib/gen'
import { createEventDispatcher, getContext } from 'svelte'
import type { FlowEditorContext } from '../flows/types'
import type { PickableProperties } from '../flows/previousResults'
import YAML from 'yaml'
@@ -17,46 +13,34 @@
import { stepInputCompletionEnabled } from '$lib/stores'
import type { SchemaProperty } from '$lib/common'
import FlowCopilotInputsModal from './FlowCopilotInputsModal.svelte'
import { twMerge } from 'tailwind-merge'
import { copilotInfo } from '$lib/aiStore'
import Button from '../common/button/Button.svelte'
import { AIBtnClasses } from './chat/AIButtonStyle'
import { Check, Wand2 } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
let generatedContent = $state('')
let loading = $state(false)
interface Props {
/** Whether the input this belongs to has focus — a suggestion is only worth keeping
* while the user is still on that input. */
focused?: boolean
arg: InputTransform | any
schemaProperty: SchemaProperty
pickableProperties?: PickableProperties | undefined
argName: string
btnClass?: string
}
let {
focused = false,
arg,
schemaProperty,
pickableProperties = undefined,
argName,
btnClass = ''
}: Props = $props()
let empty = $state(false)
run(() => {
empty =
Object.keys(arg ?? {}).length === 0 ||
(arg.type === 'static' && !arg.value) ||
(arg.type === 'javascript' && !arg.expr)
})
let { focused = false, schemaProperty, pickableProperties = undefined, argName }: Props = $props()
/** The button takes focus when clicked, which blurs the input — without tracking that,
* asking for a suggestion would immediately look like leaving the field. */
let btnFocused = $state(false)
let abortController = new AbortController()
let newFlowInput = $state('')
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
const { stepInputsLoading, generatedExprs } =
getContext<FlowCopilotContext | undefined>('FlowCopilotContext') || {}
const { generatedExprs } = getContext<FlowCopilotContext | undefined>('FlowCopilotContext') || {}
function createFlowInput() {
if (!newFlowInput) {
@@ -177,35 +161,18 @@ Only return the expression without any wrapper.`
generatedContent = ''
}
function automaticGeneration() {
if (empty) {
generateStepInput()
}
}
function cancelOnOutOfFocus() {
setTimeout(() => {
if (!focused && !btnFocused) {
// only cancel if out of focus is not due to click on btn
// Drop a suggestion once the user has moved on, so the accept button can't sit there armed
// against an input nobody is editing. Deferred because focus moves through nothing on its
// way from the input to the button, and left alone while loading: the click that asked for
// the suggestion is itself what blurred the input.
$effect(() => {
if (focused || btnFocused) return
const timer = setTimeout(() => {
if (!focused && !btnFocused && !loading) {
cancel()
}
}, 150)
}
$effect(() => {
if (!focused) {
untrack(() => {
cancelOnOutOfFocus()
})
}
})
$effect(() => {
if ($copilotInfo.enabled && $stepInputCompletionEnabled && focused) {
untrack(() => {
automaticGeneration()
})
}
return () => clearTimeout(timer)
})
$effect(() => {
@@ -216,8 +183,27 @@ Only return the expression without any wrapper.`
dispatch('showExpr', $generatedExprs?.[argName] || '')
})
let out = $state(true) // hack to prevent regenerating answer when accepting the answer due to mouseenter on new icon
let openInputsModal = $state(false)
/** A suggestion is waiting to be accepted, rather than waiting to be asked for. */
let ready = $derived(!loading && generatedContent.length > 0)
function accept() {
dispatch('setExpr', generatedContent)
if (newFlowInput) {
openInputsModal = true
}
generatedContent = ''
}
// A suggestion costs a model call, so nothing generates on its own — this button is the
// only trigger, and the same control then accepts what it produced. Blur must not cancel:
// clicking here takes focus out of the input, which is the gesture that started the call.
function onClick() {
if (loading) cancel()
else if (ready) accept()
else generateStepInput()
}
</script>
{#if $copilotInfo.enabled && $stepInputCompletionEnabled}
@@ -228,54 +214,27 @@ Only return the expression without any wrapper.`
bind:open={openInputsModal}
inputs={[newFlowInput]}
/>
<!-- Sized to match FlowPlugConnect: it shares the control row with the connect plug. -->
<Button
size="xs"
variant="default"
size="xs3"
iconOnly
{loading}
clickableWhileLoading
title={loading ? 'Cancel' : ready ? 'Accept the suggestion' : 'Suggest an expression with AI'}
startIcon={{ icon: ready ? Check : Wand2 }}
btnClasses={twMerge(
AIBtnClasses(!loading && generatedContent.length > 0 ? 'green' : 'default'),
btnClass
AIBtnClasses(ready ? 'green' : 'default'),
'bg-surface overflow-clip flex p-0'
)}
on:click={() => {
if (!loading && generatedContent.length > 0) {
dispatch('setExpr', generatedContent)
if (newFlowInput) {
openInputsModal = true
}
generatedContent = ''
}
}}
on:mouseenter={(ev) => {
if (out) {
out = false
generateStepInput()
}
}}
on:mouseleave={() => {
out = true
cancel()
}}
endIcon={{
icon:
loading || ($stepInputsLoading && empty)
? Loader2
: generatedContent.length > 0
? Check
: Wand2,
classes: loading || ($stepInputsLoading && empty) ? 'animate-spin' : ''
}}
on:focus={() => {
btnFocused = true
}}
on:blur={() => {
btnFocused = false
}}
>
{#if focused}
{#if loading}
ESC
{:else if generatedContent.length > 0}
TAB
{/if}
{/if}
</Button>
wrapperClasses={twMerge(
'h-5 w-8 p-0 group-hover:opacity-100 transition-opacity',
// Same reveal-on-hover as the connect plug beside it, but a request in flight or a
// suggestion waiting to be accepted has to stay reachable once the pointer leaves.
loading || ready ? '' : 'opacity-0'
)}
on:click={onClick}
on:focus={() => (btnFocused = true)}
on:blur={() => (btnFocused = false)}
/>
{/if}
@@ -168,10 +168,25 @@ input_name2: expression2
}
}
let out = $state(true) // hack to prevent regenerating answer when accepting the answer due to mouseenter on new icon
let openInputsModal = $state(false)
let disabled = $derived(argNames.length === 0)
/** Suggestions are in hand and waiting to be applied, rather than waiting to be asked for. */
let ready = $derived(!loading && Object.keys($generatedExprs || {}).length > 0)
function cancel() {
abortController.abort()
generatedExprs?.set({})
}
// Filling every input costs a model call, so it takes a deliberate click — the pointer
// merely crossing the button must not spend one. The same control then applies the result.
function onClick() {
if (loading) cancel()
else if (ready) applyExprs()
else generateStepInputs()
}
</script>
<div class="flex flex-row justify-end">
@@ -187,37 +202,23 @@ input_name2: expression2
size="xs"
wrapperClasses="flex-1"
variant="default"
btnClasses={twMerge(
!disabled &&
AIBtnClasses(
!loading && Object.keys($generatedExprs || {}).length > 0 ? 'green' : 'default'
)
)}
on:mouseenter={(ev) => {
if (out) {
out = false
generateStepInputs()
}
}}
on:mouseleave={() => {
out = true
abortController.abort()
generatedExprs?.set({})
}}
on:click={() => {
if (!loading && Object.keys($generatedExprs || {}).length > 0) {
applyExprs()
}
btnClasses={twMerge(!disabled && AIBtnClasses(ready ? 'green' : 'default'))}
on:click={onClick}
on:blur={() => {
// Suggestions belong to the moment they were asked for; leaving the button drops
// them so it can't sit on "Accept" against inputs the user has moved on from.
// A request still in flight is left alone — it was asked for deliberately.
if (!loading) cancel()
}}
startIcon={{
icon: loading ? Loader2 : Object.keys($generatedExprs || {}).length > 0 ? Check : Wand2,
icon: loading ? Loader2 : ready ? Check : Wand2,
classes: loading ? 'animate-spin' : ''
}}
{disabled}
>
{#if loading}
Loading
{:else if Object.keys($generatedExprs || {}).length > 0}
Cancel
{:else if ready}
Accept
{:else}
Fill inputs
@@ -889,7 +889,7 @@
white-space: pre-wrap;
word-break: break-words;
width: 100%;
min-height: 2.25rem;
min-height: 1.9rem;
}
/* Hide the textarea's own glyphs (the highlight overlay renders the text)
@@ -157,7 +157,9 @@
},
selectStep: (id) => {
selectionManager.selectId(id)
// The step's editor must actually be on screen for the user to see what the
// assistant is working on.
selectionManager.selectId(id, { openPanel: true })
},
testFlow: async (args, conversationId) => {
@@ -175,8 +177,9 @@
return { errorCount: 0, warningCount: 0, errors: [], warnings: [] }
}
// Focus the module first
selectionManager.selectId(moduleId)
// Lint is read off the mounted editor, so the panel has to be open — with it
// closed the poll below would time out and report a clean script.
selectionManager.selectId(moduleId, { openPanel: true })
// Poll until editor exists
const maxWait = 3000
+2
View File
@@ -1,6 +1,8 @@
import type { SupportedLanguage } from '$lib/common'
export type FlowBuilderWhitelabelCustomUi = {
/** Opt out of FlowEditor's `modalPanel` to keep the classic always-docked pane. */
modalPanel?: boolean
topBar?: {
path?: boolean
editablePath?: boolean
@@ -5,6 +5,7 @@
import { enterpriseLicense } from '$lib/stores'
import Label from '../Label.svelte'
import type { Schema } from '$lib/common'
import { slideDynamic } from '$lib/transitions'
let {
debounce_delay_s = $bindable(),
@@ -16,7 +17,8 @@
placeholder,
size = 'xs',
color = undefined,
fontClass = 'font-normal'
fontClass = 'font-normal',
indentContent = false
}: {
debounce_delay_s: number | undefined
debounce_key: string | undefined
@@ -28,6 +30,8 @@
size: 'xs' | 'sm'
color?: 'nord' | undefined
fontClass?: string
/** Align the fields under the toggle's label, as the flow step settings do. */
indentContent?: boolean
} = $props()
// Check if an originalType like "string | string[]" is a top-level
@@ -85,6 +89,10 @@
max_total_debounces_amount = undefined
}
})
// Presence, not truthiness: a single backspace takes the seeded 1s to 0 while the
// user is still typing, and reading that as off would disable the field mid-edit.
let off = $derived(!$enterpriseLicense || debounce_delay_s === undefined)
</script>
<div>
@@ -94,9 +102,9 @@
{color}
{size}
disabled={!$enterpriseLicense}
checked={Boolean(debounce_delay_s)}
checked={debounce_delay_s !== undefined}
on:change={() => {
if (debounce_delay_s) {
if (debounce_delay_s !== undefined) {
debounce_delay_s = undefined
debounce_key = undefined
} else {
@@ -113,10 +121,10 @@
/>
</div>
{#if debounce_delay_s}
<div class="flex flex-col gap-4 mt-2">
{#if !off}
<div class="flex flex-col gap-4 mt-2 {indentContent ? 'pl-9' : ''}" transition:slideDynamic>
<Label label="Delay in seconds">
<SecondsInput disabled={!$enterpriseLicense} bind:seconds={debounce_delay_s} />
<SecondsInput disabled={off} bind:seconds={debounce_delay_s} />
</Label>
<Label label="Custom debounce key (optional)">
{#snippet header()}
@@ -125,14 +133,7 @@
`$workspace`. You can also use an argument's value using `$args[name_of_arg]`</Tooltip
>
{/snippet}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
autofocus
disabled={!$enterpriseLicense}
bind:value={debounce_key}
{placeholder}
/>
<input type="text" disabled={off} bind:value={debounce_key} {placeholder} />
</Label>
<Label label="Argument to accumulate (optional)">
{#snippet header()}
@@ -141,7 +142,7 @@
debounced execution will be appended together.</Tooltip
>
{/snippet}
<select disabled={!$enterpriseLicense} bind:value={selectedArg}>
<select disabled={off} bind:value={selectedArg}>
<option value="">None</option>
{#each arrayArgs as arg}
<option value={arg}>{arg}</option>
@@ -161,7 +162,7 @@
this time is reached, the job will run regardless of ongoing debouncing.</Tooltip
>
{/snippet}
<SecondsInput disabled={!$enterpriseLicense} bind:seconds={max_total_debouncing_time} />
<SecondsInput disabled={off} bind:seconds={max_total_debouncing_time} />
</Label>
<Label label="Max total debounces amount (optional)">
{#snippet header()}
@@ -170,12 +171,7 @@
is reached, the job will run regardless of ongoing debouncing.</Tooltip
>
{/snippet}
<input
type="number"
disabled={!$enterpriseLicense}
bind:value={max_total_debounces_amount}
min="0"
/>
<input type="number" disabled={off} bind:value={max_total_debounces_amount} min="0" />
</Label>
</div>
{/if}
@@ -1,12 +1,17 @@
<script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes'
import Disposable from '$lib/components/common/drawer/Disposable.svelte'
import FlowEditorPanel from './content/FlowEditorPanel.svelte'
import FlowModuleSchemaMap from './map/FlowModuleSchemaMap.svelte'
import type { OpenInSessionSource } from '$lib/components/sessions/OpenInSessionButton.svelte'
import WindmillIcon from '../icons/WindmillIcon.svelte'
import { Skeleton } from '../common'
import { getContext, onDestroy, onMount, setContext } from 'svelte'
import type { FlowEditorContext } from './types'
import { getContext, onDestroy, onMount, setContext, untrack } from 'svelte'
import type { FlowEditorContext, FlowPanelDetachContext } from './types'
import { getOverlayHost } from '$lib/components/common/overlayHost.svelte'
import Portal from '$lib/components/Portal.svelte'
import { isFlowLevelPanelTarget } from '$lib/components/graph/selectionUtils.svelte'
import { useFlowPanelMode } from './flowPanelMode.svelte'
import { writable } from 'svelte/store'
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
@@ -26,7 +31,10 @@
import type { FlowOptions } from '../copilot/chat/ContextManager.svelte'
import { extractAllModules } from '../copilot/chat/shared'
import type { Snippet } from 'svelte'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
import { Button } from '../common'
import { MousePointerClick, X } from 'lucide-svelte'
import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte'
const { flowStore, selectionManager } = getContext<FlowEditorContext>('FlowEditorContext')
const sessionScopedManager = getContext<AIChatManager>('aiChatManager')
const aiChatManager = sessionScopedManager ?? singletonAiChatManager
@@ -69,6 +77,9 @@
flowHasChanged?: boolean
previewOpen: boolean
graphOverlay?: Snippet
/** Allow the step-details pane to open as a modal. Whitelabel embeds turn this off
* to keep the classic always-docked pane. */
modalPanel?: boolean
}
let {
@@ -105,11 +116,84 @@
onDelete,
flowHasChanged,
previewOpen,
graphOverlay
graphOverlay,
modalPanel = true
}: Props = $props()
let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state()
// 'docked' = normal split pane; 'modal' = graph full-width, panel in a modal opened by
// double-clicking a node. The controller resolves it from the user's Auto/Attached/
// Detached preference and the width measured below.
const panelController = useFlowPanelMode({ enabled: () => modalPanel })
const panelMode = $derived(panelController.mode)
let panelModalOpen = $state(false)
// Auto can move the panel back into the pane under a modal that is open — leaving it
// open would keep an overlay registered for a modal nothing renders, swallowing Escape.
$effect(() => {
if (panelMode === 'docked' && untrack(() => panelModalOpen)) {
panelModalOpen = false
}
})
let panelDisposable: Disposable | undefined = $state(undefined)
// Disposable joins the stack through its methods, not by watching `open` — same sync
// as Drawer and Modal, so setting `panelModalOpen` anywhere still registers the overlay.
$effect(() => {
panelModalOpen
untrack(() => {
panelModalOpen ? panelDisposable?.openDrawer() : panelDisposable?.closeDrawer()
})
})
const overlayHost = getOverlayHost()
const modalHost = $derived(overlayHost?.el())
// Only nodes that can take the selection, or the modal would open on whatever was
// selected before — asset and note nodes are deliberately unselectable.
//
// The In/Out bar sits inside the node but is a picker of its own: it toggles open and
// shut on click, so opening then closing it is a double-click the graph must not read
// as "show me this step".
function selectableNodeAt(e: MouseEvent): HTMLElement | null {
const target = e.target as HTMLElement | null
if (target?.closest('[data-prop-picker]')) return null
return target?.closest('.svelte-flow__node.selectable') ?? null
}
function openPanelModalFromGraph(e: MouseEvent) {
if (selectableNodeAt(e)) {
panelModalOpen = true
}
}
// A click on the step that is already selected is the second half of "select it, then
// show it". Read in the capture phase: by the time the click bubbles here the graph has
// applied its own selection, so a first click would look indistinguishable from this.
let clickStartedOnSelected = false
function noteSelectionBeforeClick(e: MouseEvent) {
const node = selectableNodeAt(e)
clickStartedOnSelected =
Boolean(node?.classList.contains('selected')) && selectionManager.selectedIds.length === 1
}
function openPanelModalIfReselected(e: MouseEvent) {
if (clickStartedOnSelected && selectableNodeAt(e)) {
panelModalOpen = true
}
}
// In modal mode a step's editor is a click or two away but invisible until then —
// keep a standing hint whenever the graph is showing (modal closed).
const showStepHint = $derived.by(() => panelMode === 'modal' && !panelModalOpen)
const stepHintText = $derived.by(() => {
const ids = selectionManager.selectedIds
return ids.length === 1 && !isFlowLevelPanelTarget(ids[0])
? 'Click the selected step to explore its content'
: 'Double click a step to explore its content'
})
// When the graph pane is narrow, fall back to a top-centered overlay so the
// preview buttons don't overlap the rightmost node ports (matches the dev
// page layout).
@@ -124,9 +208,46 @@
flowModuleSchemaMap?.enableNotes?.()
}
const flowPropPickerConfig = writable<FlowPropPickerConfig | undefined>(undefined)
// Closing the modal unmounts the panel that started a graph connect, but the config
// outlives it — a later pick would run a closure over a step nobody is editing.
$effect(() => {
if (!panelModalOpen) {
flowPropPickerConfig.set(undefined)
}
})
setContext<PropPickerContext>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined)
flowPropPickerConfig,
pickablePropertiesFiltered: writable<PickableProperties | undefined>(undefined),
inModalPanel: () => panelMode === 'modal'
})
// Read by graph step items (VirtualItem) to show a per-step "explore" hint on hover,
// since in modal mode a step's editor is hidden until a double-click, or a click on the
// step that is already selected.
setContext<() => boolean>('flowGraphStepExploreHint', () => panelMode === 'modal')
// The panel's chrome lives inline in its card header (no dedicated row); panels without
// a card header get FlowEditor's fallback strip instead, driven by the claim count.
let detachClaims = $state(0)
setContext<FlowPanelDetachContext>('flowPanelDetach', {
claim: () => {
detachClaims++
return () => detachClaims--
},
modalOpen: () => modalPanel && panelMode === 'modal' && panelModalOpen,
close: () => (panelModalOpen = false),
enabled: () => modalPanel,
preference: () => panelController.preference,
setPreference: (preference) => {
// Moving the panel must not lose what it was showing: docked, it is always on
// screen, so the modal it becomes has to open on arrival. The reverse is handled
// by the effect above, which closes a modal that is no longer rendered.
const wasVisible = panelMode === 'docked' || panelModalOpen
panelController.preference = preference
panelModalOpen = panelController.mode === 'modal' && wasVisible
}
})
$effect(() => {
@@ -141,6 +262,14 @@
})
onMount(() => {
if (modalPanel) {
selectionManager.setOnSelectIntent((id, opts) => {
if (opts?.openPanel === false) return
if (panelMode === 'modal' && (opts?.openPanel || isFlowLevelPanelTarget(id))) {
panelModalOpen = true
}
})
}
if (!sessionScopedManager) {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.FLOW)
@@ -149,6 +278,9 @@
onDestroy(() => {
aiChatManager.flowOptions = undefined
if (modalPanel) {
selectionManager.setOnSelectIntent(undefined)
}
if (!sessionScopedManager) {
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.NAVIGATOR)
@@ -156,18 +288,44 @@
})
</script>
{#snippet panelBody()}
<FlowEditorPanel
{disabledFlowInputs}
{newFlow}
{savedFlow}
enableAi={!disableAi}
on:applyArgs
on:testWithArgs
{onDeployTrigger}
{forceTestTab}
{highlightArg}
{onTestFlow}
{job}
{isOwner}
{suspendStatus}
onOpenDetails={onOpenPreview}
{previewOpen}
{flowModuleSchemaMap}
/>
{/snippet}
<div
bind:clientWidth={null, (w) => panelController.measure(w)}
id="flow-editor"
class={'h-full overflow-hidden transition-colors duration-[400ms] ease-linear border-t'}
class={'relative h-full overflow-hidden transition-colors duration-[400ms] ease-linear border-t'}
use:triggerableByAI={{
id: 'flow-editor',
description: 'Component to edit a flow'
}}
>
<Splitpanes>
<Pane size={50} minSize={15} class="h-full relative z-0">
<Pane size={panelMode === 'docked' ? 50 : 100} minSize={15} class="h-full relative z-0">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:clientWidth={graphPaneWidth}
ondblclick={panelMode === 'modal' ? openPanelModalFromGraph : undefined}
onpointerdowncapture={panelMode === 'modal' ? noteSelectionBeforeClick : undefined}
onclick={panelMode === 'modal' ? openPanelModalIfReselected : undefined}
class="grow overflow-hidden bg-gray h-full bg-surface-secondary relative"
>
{#if graphOverlay}
@@ -226,36 +384,96 @@
{/if}
</div>
</Pane>
<Pane class="relative z-10" size={50} minSize={20}>
{#if loading}
<div class="w-full h-full">
<div class="block m-auto pt-40 w-10">
<WindmillIcon height="40px" width="40px" spin="fast" />
{#if panelMode === 'docked'}
<!-- Panels manage their own scrolling, so the pane must not scroll as well or a second
scrollbar appears beside theirs. `!` because splitpanes' own `overflow: auto` rule
has equal specificity and wins on cascade order. -->
<Pane class="relative z-10 !overflow-hidden" size={50} minSize={20}>
{#if loading}
<div class="w-full h-full">
<div class="block m-auto pt-40 w-10">
<WindmillIcon height="40px" width="40px" spin="fast" />
</div>
</div>
</div>
{:else}
<FlowEditorPanel
{disabledFlowInputs}
{newFlow}
{savedFlow}
enableAi={!disableAi}
on:applyArgs
on:testWithArgs
{onDeployTrigger}
{forceTestTab}
{highlightArg}
{onTestFlow}
{job}
{isOwner}
{suspendStatus}
onOpenDetails={onOpenPreview}
{previewOpen}
{flowModuleSchemaMap}
/>
{/if}
</Pane>
{:else if modalPanel}
<div class="flex h-full flex-col">
<!-- Fallback for panels without a card header hosting the placement
picker: a slim strip so moving the panel stays reachable. Toggled
around a stable panelBody — re-parenting it would re-mount
the claiming header and loop. -->
{#if detachClaims === 0}
<div class="flex items-center justify-end border-b px-1">
<FlowPanelPlacementPicker variant="header" />
</div>
{/if}
<div class="min-h-0 flex-1">
{@render panelBody()}
</div>
</div>
{:else}
{@render panelBody()}
{/if}
</Pane>
{/if}
{#if !disableAi}
<FlowAIChat {flowModuleSchemaMap} {onTestFlow} />
{/if}
</Splitpanes>
{#if showStepHint}
<div
class="pointer-events-none absolute bottom-2 left-3 z-30 flex items-center gap-1.5 text-xs text-hint"
>
<MousePointerClick size={13} />
{stepHintText}
</div>
{/if}
</div>
<!-- Portalled out of `#flow-editor` so the modal covers the chrome around the editor
(sidebar, top bar) rather than only the editor's own box. A host that embeds the
editor in its own box provides an anchor element instead, keeping the modal inside
it — one flow editor's modal must never cover a sibling's tab. -->
<!-- Disposable owns the overlay stack: it takes a place while open, arbitrates Escape against
whatever else is open in this pane, and stays quiet while the pane is hidden. -->
<Disposable bind:open={panelModalOpen} bind:this={panelDisposable}>
{#snippet children({ zIndex })}
{#if panelMode === 'modal' && panelModalOpen}
<Portal target={modalHost ?? 'body'} class="contents">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="{modalHost ? 'absolute' : 'fixed'} inset-0 flex justify-center px-2 py-6"
style="z-index: {zIndex}"
role="dialog"
>
<div class="absolute inset-0 bg-black/20" onclick={() => (panelModalOpen = false)}></div>
<div
class="relative flex w-full max-w-4xl flex-col overflow-hidden rounded-md border bg-surface shadow-xl"
>
<!-- Same fallback as the docked strip: a panel whose body has a card header
hosts the id, placement and close inline, so this bar would double it. -->
{#if detachClaims === 0}
<div class="flex items-center justify-end gap-2 border-b px-2 py-1">
<div class="flex items-center gap-0.5">
<FlowPanelPlacementPicker variant="header" />
<Button
size="xs2"
variant="subtle"
iconOnly
startIcon={{ icon: X }}
title="Close"
on:click={() => (panelModalOpen = false)}
/>
</div>
</div>
{/if}
<div class="min-h-0 flex-1 overflow-auto">
{@render panelBody()}
</div>
</div>
</div>
</Portal>
{/if}
{/snippet}
</Disposable>
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest'
import { writable, get } from 'svelte/store'
import type { FlowModule } from '$lib/gen'
import type { StateStore } from '$lib/utils'
import type { ExtendedOpenFlow } from './types'
import { reorderBranches } from './branchOps'
function branch(expr: string) {
return { summary: expr, expr, modules: [] as FlowModule[] }
}
function ctx(branches: ReturnType<typeof branch>[]) {
const flow = {
summary: '',
value: {
modules: [{ id: 'a', value: { type: 'branchone', branches, default: [] } } as FlowModule]
}
} as ExtendedOpenFlow
const flowStore: StateStore<ExtendedOpenFlow> = { val: flow }
const history = writable({ history: [] as ExtendedOpenFlow[], index: -1 })
const read = () =>
(flowStore.val.value.modules[0].value as { branches: ReturnType<typeof branch>[] }).branches
return { flowStore, history, read }
}
describe('reorderBranches', () => {
it('commits the new order', () => {
const [x, y, z] = [branch('x'), branch('y'), branch('z')]
const { flowStore, history, read } = ctx([x, y, z])
reorderBranches('a', [z, x, y], { flowStore, history })
expect(read().map((b) => b.expr)).toEqual(['z', 'x', 'y'])
})
it('records an undo entry, so a drag can be undone like an add or a delete', () => {
const [x, y] = [branch('x'), branch('y')]
const { flowStore, history } = ctx([x, y])
reorderBranches('a', [y, x], { flowStore, history })
expect(get(history).history).toHaveLength(1)
})
it('spends nothing when the drag lands where it started', () => {
// `finalize` fires on every drop, so a no-op drag must not consume an undo step.
const [x, y] = [branch('x'), branch('y')]
const { flowStore, history, read } = ctx([x, y])
reorderBranches('a', [x, y], { flowStore, history })
expect(get(history).history).toHaveLength(0)
expect(read().map((b) => b.expr)).toEqual(['x', 'y'])
})
})
@@ -0,0 +1,82 @@
import type { FlowModule } from '$lib/gen'
import type { ExtendedOpenFlow } from './types'
import type { FlowState } from './flowState'
import type { StateStore } from '$lib/utils'
import type { History } from '$lib/history.svelte'
import { push } from '$lib/history.svelte'
import { dfs } from './dfs'
import { findModuleInFlow } from './flowTree'
type BranchList = Array<{ summary?: string; expr?: string; modules: FlowModule[] }>
type Ctx = {
flowStore: StateStore<ExtendedOpenFlow>
flowStateStore: StateStore<FlowState>
history: History<ExtendedOpenFlow>
}
/** Append an empty branch to a branchone/branchall step. */
export function addBranch(moduleId: string, { flowStore, history }: Omit<Ctx, 'flowStateStore'>) {
push(history, flowStore.val)
const module = findModuleInFlow(flowStore.val.value, moduleId)
if (!module) throw new Error(`Node ${moduleId} not found`)
if (module.value.type === 'branchone' || module.value.type === 'branchall') {
module.value.branches.push({ summary: '', expr: 'false', modules: [] })
}
}
/**
* Drop a branch and the flow state of every step inside it.
*
* `index` counts the way the graph lays the branches out, where a branchone's default
* occupies slot 0 one ahead of the same branch's position in `value.branches`. Callers
* working from the array (the settings panel) must add that offset back.
*/
export function removeBranch(
moduleId: string,
index: number,
{ flowStore, flowStateStore, history }: Ctx
) {
push(history, flowStore.val)
const module = findModuleInFlow(flowStore.val.value, moduleId)
if (!module) throw new Error(`Node ${moduleId} not found`)
if (module.value.type === 'branchone' || module.value.type === 'branchall') {
const offset = module.value.type === 'branchone' ? 1 : 0
const at = index - offset
if (module.value.branches[at]?.modules) {
const leaves = dfs(module.value.branches[at].modules, (mod) => mod.id)
leaves.forEach((leafId: string) => delete flowStateStore.val[leafId])
}
module.value.branches.splice(at, 1)
}
}
/**
* Commit a reordered branch list. Undoable like add/remove a drag is a structural edit,
* and for a branchone it changes which predicate is evaluated first.
*/
export function reorderBranches(
moduleId: string,
ordered: BranchList,
{ flowStore, history }: Omit<Ctx, 'flowStateStore'>
) {
const module = findModuleInFlow(flowStore.val.value, moduleId)
if (!module) throw new Error(`Node ${moduleId} not found`)
if (module.value.type !== 'branchone' && module.value.type !== 'branchall') return
const current = module.value.branches
// A drag that lands where it started must not spend an undo entry.
if (ordered.length === current.length && ordered.every((b, i) => b === current[i])) return
push(history, flowStore.val)
module.value.branches = ordered as typeof current
}
/** Slot a branch occupies in the graph's numbering, from its index in `value.branches`. */
export function graphBranchIndex(type: 'branchone' | 'branchall', arrayIndex: number): number {
return type === 'branchone' ? arrayIndex + 1 : arrayIndex
}
@@ -6,6 +6,8 @@
title?: string | undefined
summary?: string | undefined
description?: string | undefined
subtitle?: string | undefined
subtitleDocLink?: string | undefined
noEditor: boolean
noHeader?: boolean
flowModuleValue?: FlowModuleValue | undefined
@@ -20,6 +22,8 @@
title = undefined,
summary = $bindable(undefined),
description = $bindable(undefined),
subtitle = undefined,
subtitleDocLink = undefined,
noEditor,
noHeader = false,
flowModuleValue = undefined,
@@ -37,9 +41,12 @@
<FlowCardHeader
on:setHash
on:reload
on:fork
{title}
bind:summary
bind:description
{subtitle}
{subtitleDocLink}
{flowModuleValue}
{action}
{isAgentTool}
@@ -1,26 +1,30 @@
<script lang="ts" module>
let cachedValues: Record<
string,
{
latestHash: string | undefined
}
> = {}
</script>
<script lang="ts">
import Badge from '$lib/components/common/badge/Badge.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
import MetadataGen from '$lib/components/copilot/MetadataGen.svelte'
import IconedPath from '$lib/components/IconedPath.svelte'
import { ScriptService, type FlowModuleValue, type PathScript } from '$lib/gen'
import { hubBaseUrlStore, workspaceStore } from '$lib/stores'
import { Flag, Lock, RefreshCw, Unlock } from 'lucide-svelte'
import { ScriptService, type FlowModuleValue } from '$lib/gen'
import {
ArrowUpCircle,
Flag,
GitFork,
Lock,
Pen,
RefreshCw,
Settings,
Unlock
} from 'lucide-svelte'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import type { FlowEditorContext } from '../types'
import FlowPanelChrome from './FlowPanelChrome.svelte'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { hubBaseUrlStore, workspaceStore } from '$lib/stores'
import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
import { getLatestHashForScript } from '$lib/scripts'
import { sendUserToast, type Item } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import { getToolNameError } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte'
import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
import autosize from '$lib/autosize'
interface Props {
@@ -28,6 +32,10 @@
title?: string | undefined
summary?: string | undefined
description?: string | undefined
/** Static one-line explanation of what this kind of step does. Not the editable
* `description`, which is the AI-tool prompt the user writes. */
subtitle?: string | undefined
subtitleDocLink?: string | undefined
children?: import('svelte').Snippet
action?: import('svelte').Snippet
isAgentTool?: boolean
@@ -39,6 +47,8 @@
title = undefined,
summary = $bindable(undefined),
description = $bindable(undefined),
subtitle = undefined,
subtitleDocLink = undefined,
children,
action,
isAgentTool = false,
@@ -49,50 +59,125 @@
isAgentTool ? getToolNameError(summary ?? '', undefined, siblingToolNames) : undefined
)
let latestHash: string | undefined = $state(undefined)
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
// Extract version_id from hub path (format: hub/{version_id}/{app}/{summary})
let hubVersionId = $derived(
flowModuleValue?.type === 'script' && flowModuleValue.path?.startsWith('hub/')
? flowModuleValue.path.split('/')[1]
: undefined
)
function getCachedKey(path: string) {
return `${opWs}-${path}`
}
function getCachedValues(path: string) {
const key = getCachedKey(path)
latestHash = cachedValues[key]?.latestHash
}
const untrackedFlowModuleValue = untrack(() => flowModuleValue)
if (untrackedFlowModuleValue?.type === 'script' && untrackedFlowModuleValue.path) {
getCachedValues(untrackedFlowModuleValue.path)
}
async function loadLatestHash(value: PathScript) {
let script = await ScriptService.getScriptByPath({
workspace: opWs!,
path: value.path
})
const key = getCachedKey(value.path)
cachedValues[key] = {
latestHash: script.hash
}
latestHash = script.hash
}
const dispatch = createEventDispatcher()
const customUi: FlowBuilderWhitelabelCustomUi | undefined = getContext('customUi')
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
const { scriptEditorDrawer, workspaceScriptSettingsDrawer } = flowEditorContext
$effect.pre(() => {
$workspaceStore &&
flowModuleValue?.type === 'script' &&
flowModuleValue.path &&
!flowModuleValue.path.startsWith('hub/') &&
untrack(() => loadLatestHash(flowModuleValue))
let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
const scriptPath = $derived(flowModuleValue?.type === 'script' ? flowModuleValue.path : undefined)
const pinnedHash = $derived(flowModuleValue?.type === 'script' ? flowModuleValue.hash : undefined)
const isHub = $derived(scriptPath?.startsWith('hub/') ?? false)
// Version id out of a hub path: hub/{version_id}/{app}/{summary}
const hubVersionId = $derived(isHub ? scriptPath?.split('/')[1] : undefined)
let latestHash: string | undefined = $state(undefined)
$effect(() => {
const path = scriptPath
if (!opWs || !path || isHub) return
untrack(async () => {
latestHash = (await ScriptService.getScriptByPath({ workspace: opWs, path })).hash
})
})
function reportIssue() {
const targetHubBaseUrl =
Number(hubVersionId) < PRIVATE_HUB_MIN_VERSION ? DEFAULT_HUB_BASE_URL : $hubBaseUrlStore
window.open(
`${targetHubBaseUrl}/from_version/${hubVersionId}?report_issue=${hubVersionId}`,
'_blank'
)
}
// Every one of these acts on the referenced script rather than the step, so they share
// a single menu instead of a row of icon buttons.
const scriptItems: Item[] = $derived.by(() => {
if (flowModuleValue?.type !== 'script') return []
const items: Item[] = []
if (!isHub && customUi?.scriptEdit != false) {
items.push({
displayName: "Edit the script's code",
icon: Pen,
disabled: pinnedHash != undefined,
tooltip: pinnedHash != undefined ? 'Unlock the hash to edit' : undefined,
action: async () => {
if (flowModuleValue?.type !== 'script') return
const hash =
flowModuleValue.hash ?? (await getLatestHashForScript(flowModuleValue.path, opWs))
$scriptEditorDrawer?.openDrawer(hash, () => {
dispatch('reload')
sendUserToast('Script has been updated')
})
}
})
// Only when the settings drawer is actually mounted (not in the local-dev
// editors, which provide the context store but never render it).
if ($workspaceScriptSettingsDrawer) {
items.push({
displayName: 'Runtime settings',
icon: Settings,
disabled: pinnedHash != undefined,
tooltip: 'Concurrency, cache, timeout, …',
action: () => {
if (flowModuleValue?.type !== 'script') return
$workspaceScriptSettingsDrawer?.openDrawer(
flowModuleValue.path,
flowModuleValue.hash,
() => dispatch('reload')
)
}
})
}
}
if (customUi?.scriptFork != false) {
items.push({
displayName: 'Fork into an inline script',
icon: GitFork,
action: () => dispatch('fork')
})
}
if (pinnedHash) {
if (latestHash && latestHash !== pinnedHash) {
items.push({
displayName: 'Update to latest hash',
icon: ArrowUpCircle,
separatorTop: items.length > 0,
action: () => {
dispatch('setHash', latestHash)
dispatch('reload')
}
})
}
items.push({
displayName: 'Unlock hash',
icon: Unlock,
tooltip: 'Always use the latest deployed version at that path',
separatorTop: items.length > 0 && !items.at(-1)?.separatorTop,
action: () => dispatch('setHash', undefined)
})
} else if (latestHash) {
items.push({
displayName: 'Lock hash',
icon: Lock,
tooltip: 'Always use this specific version',
separatorTop: items.length > 0,
action: () => dispatch('setHash', latestHash)
})
items.push({
displayName: 'Reload latest hash',
icon: RefreshCw,
action: () => dispatch('reload')
})
}
if (hubVersionId) {
items.push({
displayName: 'Report issue',
icon: Flag,
separatorTop: items.length > 0,
action: reportIssue
})
}
return items
})
</script>
@@ -101,8 +186,8 @@
class="overflow-x-auto scrollbar-hidden flex items-center justify-between flex-nowrap w-full"
>
{#if flowModuleValue}
<span class="text-sm w-full mr-4">
<div class="flex items-center space-x-2">
<span class="mr-4 min-w-0 flex-1 text-sm">
<div class="flex min-w-0 items-center space-x-2">
{#if flowModuleValue.type === 'identity'}
<span class="font-bold text-xs">Identity (input copied to output)</span>
{:else if flowModuleValue.type === 'rawscript'}
@@ -121,75 +206,16 @@
{siblingToolNames}
/>
{:else if flowModuleValue.type === 'script' && 'path' in flowModuleValue && flowModuleValue.path}
<IconedPath path={flowModuleValue.path} hash={flowModuleValue.hash} class="grow" />
{#if hubVersionId}
<Button
title="Report an issue with this hub script"
unifiedSize="sm"
variant="subtle"
on:click={() => {
const targetHubBaseUrl =
Number(hubVersionId) < PRIVATE_HUB_MIN_VERSION
? DEFAULT_HUB_BASE_URL
: $hubBaseUrlStore
window.open(
`${targetHubBaseUrl}/from_version/${hubVersionId}?report_issue=${hubVersionId}`,
'_blank'
)
}}
>
<Flag size={12} />Report issue
</Button>
<IconedPath
path={flowModuleValue.path}
hash={flowModuleValue.hash}
class="!w-auto shrink min-w-0"
/>
{#if scriptItems.length > 0}
<DropdownV2 size="sm" placement="bottom-end" items={scriptItems} />
{/if}
{#if flowModuleValue.hash}
{#if latestHash != flowModuleValue.hash}
<Button
size="xs"
variant="default"
on:click={() => {
if (flowModuleValue.type == 'script') {
dispatch('setHash', latestHash)
}
dispatch('reload')
}}>Update to latest hash</Button
>
{/if}
<Button
title="Unlock hash to always use latest deployed version at that path"
size="xs"
btnClasses="text-primary inline-flex gap-1 items-center"
color="light"
on:click={() => {
if (flowModuleValue.type == 'script') {
dispatch('setHash', undefined)
}
}}><Unlock size={12} />hash</Button
>
{:else if latestHash}
<div class="flex gap-2">
<Button
title="Lock hash to always use this specific version"
unifiedSize="sm"
variant="default"
on:click={() => {
if (flowModuleValue.type == 'script') {
dispatch('setHash', latestHash)
}
}}><Lock size={12} />hash</Button
>
<Button
title="Reload latest hash"
unifiedSize="sm"
variant="default"
on:click={() => dispatch('reload')}
startIcon={{ icon: RefreshCw }}
iconOnly
/>
</div>
{/if}
<div class="flex flex-col w-full grow">
<div class="flex min-w-[8rem] flex-1 flex-col">
<input
bind:value={summary}
placeholder={isAgentTool ? 'Tool name' : 'Summary'}
@@ -210,11 +236,27 @@
</span>
{/if}
{#if title}
<div class="text-sm font-bold text-primary pr-2">{title}</div>
<!-- Absorbs the free space so the actions stay together on the right: with
justify-between alone, adding the detach button centres them. -->
<div class="mr-auto truncate pr-2 text-sm font-semibold text-emphasis">{title}</div>
{/if}
{@render children?.()}
{@render action?.()}
<FlowPanelChrome />
</div>
{#if subtitle}
<p class="text-xs leading-snug text-tertiary">
{subtitle}
{#if subtitleDocLink}
<a
href={subtitleDocLink}
target="_blank"
rel="noreferrer"
class="text-blue-500 hover:underline">Docs</a
>
{/if}
</p>
{/if}
{#if isAgentTool}
{#if toolNameError}
<p class="text-3xs text-red-400 leading-tight w-full">{toolNameError}</p>
@@ -0,0 +1,29 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import { X } from 'lucide-svelte'
import { getContext, onMount } from 'svelte'
import type { FlowPanelDetachContext } from '../types'
import FlowPanelPlacementPicker from './FlowPanelPlacementPicker.svelte'
const panelDetach = getContext<FlowPanelDetachContext | undefined>('flowPanelDetach')
// The detached modal draws no header of its own, so the header this sits in carries its
// chrome too. onMount, not $effect: claim() increments (reads+writes) the claim count,
// and a tracking effect would re-run on its own write.
onMount(() => panelDetach?.claim())
</script>
<div class="ml-2 flex shrink-0 items-center">
<FlowPanelPlacementPicker variant="header" />
</div>
{#if panelDetach?.modalOpen()}
<Button
unifiedSize="sm"
variant="subtle"
iconOnly
wrapperClasses="shrink-0"
startIcon={{ icon: X }}
title="Close"
onClick={() => panelDetach.close()}
/>
{/if}
@@ -0,0 +1,70 @@
<script lang="ts">
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { PanelRight } from 'lucide-svelte'
import { getContext } from 'svelte'
import type { Placement } from '@floating-ui/core'
import type { FlowPanelDetachContext } from '../types'
interface Props {
/** 'control' borrows the graph control bar's cell styling; 'header' matches the icon
* buttons beside it in the panel's card header. */
variant: 'control' | 'header'
placement?: Placement
}
let { variant, placement = 'bottom-end' }: Props = $props()
const panelDetach = getContext<FlowPanelDetachContext | undefined>('flowPanelDetach')
// Named options with a check, no per-row icons: whether the panel is attached is plain
// from the layout, so the only thing worth spelling out is which of the three is active —
// and Auto is the one name that doesn't say what it follows.
const PANEL_PREFERENCES = [
{
value: 'auto' as const,
displayName: 'Auto',
tooltip: 'Follows the editor width: attached when there is room, detached when not'
},
{ value: 'docked' as const, displayName: 'Attached' },
{ value: 'modal' as const, displayName: 'Detached' }
]
const items = $derived(
PANEL_PREFERENCES.map((p) => ({
displayName: p.displayName,
tooltip: p.tooltip,
selected: panelDetach?.preference() === p.value,
action: () => panelDetach?.setPreference(p.value)
}))
)
</script>
{#if panelDetach?.enabled()}
<DropdownV2
{items}
{placement}
customWidth={220}
class={variant === 'control' ? 'svelte-flow__controls-button !justify-center' : 'shrink-0'}
>
{#snippet buttonReplacement()}
{#if variant === 'control'}
<!-- The trigger itself carries the control-bar cell class. Wrapping a ControlButton
instead would nest a sized 32x30 box inside the trigger's own box, growing the
bar, and would take `:last-child` off the real last cell so its divider stayed. -->
<span class="flex" title="Where the step panel opens">
<PanelRight size="14" />
</span>
{:else}
<Button
nonCaptureEvent
unifiedSize="sm"
variant="subtle"
iconOnly
startIcon={{ icon: PanelRight }}
title="Where the step panel opens"
/>
{/if}
{/snippet}
</DropdownV2>
{/if}
@@ -50,7 +50,7 @@
/>
{:else if isMcpTool(tool)}
<!-- MCP tool - use McpToolEditor -->
<McpToolEditor bind:tool />
<McpToolEditor bind:tool {noEditor} />
{:else if isWebsearchTool(tool)}
<WebsearchToolDisplay />
<WebsearchToolDisplay {noEditor} />
{/if}
@@ -1,14 +1,13 @@
<script lang="ts">
import type { FlowModule } from '$lib/gen'
import { getContext } from 'svelte'
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { getStepPropPicker } from '../previousResults'
import type { FlowEditorContext } from '../types'
import Button from '$lib/components/common/button/Button.svelte'
import { Pen } from 'lucide-svelte'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import PredicateGen from '$lib/components/copilot/PredicateGen.svelte'
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { emptySchema } from '$lib/utils'
interface Props {
branch: {
@@ -21,13 +20,18 @@
enableAi?: boolean
}
let { branch = $bindable(), parentModule, previousModule, enableAi = false }: Props = $props()
let { branch, parentModule, previousModule, enableAi = false }: Props = $props()
let editor: SimpleEditor | undefined = $state(undefined)
// The predicate is a bare string on `branch`, so the form is told its kind through
// `argType` rather than inferring one from the value.
let predicateSchema = $state(emptySchema())
predicateSchema.properties['expr'] = { type: 'boolean' }
const { previewArgs, flowStateStore, flowStore } =
getContext<FlowEditorContext>('FlowEditorContext')
let editor: SimpleEditor | undefined = $state(undefined)
let open = $state(false)
let stepPropPicker = $derived(
getStepPropPicker(
flowStateStore.val,
@@ -41,49 +45,51 @@
)
</script>
{#if open}
<PropPickerWrapper
notSelectable
<PropPickerWrapper
popover={true}
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
displayContext={false}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<!-- `branch` itself is the arg: the form reads and writes `arg.expr` in place, which
is exactly where the predicate lives. Its other keys are inert here. -->
<InputTransformForm
bind:arg={branch}
argName="expr"
argType="javascript"
label="Run this branch if"
headerTooltip="The first branch whose expression evaluates to true is the one that runs."
noDynamicToggle
schema={predicateSchema}
previousModuleId={previousModule?.id}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
paneClass="max-h-[320px] overflow-auto"
extraLib={stepPropPicker.extraLib}
bind:editor
>
<div class="border border-gray-400">
<SimpleEditor
bind:this={editor}
lang="javascript"
bind:code={branch.expr}
class="small-editor border "
shouldBindKey={false}
extraLib={stepPropPicker.extraLib}
/>
</div>
</PropPickerWrapper>
{:else}
<div class="flex justify-between gap-4 p-2">
<div class="truncate"><pre class="text-sm truncate">{branch.expr}</pre></div>
<div class="flex flex-row gap-2 items-center">
{#snippet aiGen()}
{#if enableAi}
<PredicateGen
on:setExpr={(e) => {
branch.expr = e.detail
// Monaco owns its buffer once mounted: writing the value alone leaves
// the visible code stale until the editor is torn down and rebuilt.
editor?.setCode(e.detail)
}}
on:updateSummary={(e) => {
// The prompt names the branch better than "Branch 2" does, but only
// when the user hasn't already named it themselves.
if (!branch.summary) {
branch.summary = e.detail
}
}}
on:updateSummary
pickableProperties={stepPropPicker.pickableProperties}
/>
{/if}
<Button
size="xs"
startIcon={{ icon: Pen }}
variant="default"
on:click={() => (open = !open)}
id="flow-editor-edit-predicate"
>
Edit predicate
</Button>
</div>
</div>
{/if}
{/snippet}
</InputTransformForm>
</PropPickerWrapper>
@@ -3,11 +3,12 @@
import Button from '$lib/components/common/button/Button.svelte'
import { ChevronDown } from 'lucide-svelte'
let opened = $state(false);
let opened = $state(false)
</script>
<div class="text-xs flex flex-row-reverse">
<!-- Pulled up against the input above: it reads as a footnote on that field rather than
as the next thing in the form. -->
<div class="text-2xs flex flex-row-reverse -mt-2">
<Button
on:click={() => {
opened = !opened
@@ -16,7 +17,7 @@
variant="divider"
size="xs2"
endIcon={{ icon: ChevronDown, classes: `rotate-0 duration-300 ${opened ? '!rotate-180' : ''}` }}
btnClasses="text-hint text-2xs font-normal pt-1"
btnClasses="text-hint !text-2xs font-normal py-0"
>
Help
</Button>
@@ -15,6 +15,7 @@
} from '../expandedSubflowStep'
import { parseExpandedSubflowId } from '$lib/components/restartFromStepPath'
import { base } from '$app/paths'
import FlowPanelChrome from '../common/FlowPanelChrome.svelte'
interface Props {
/** Graph node id of the selected step, of the form `subflow:<step>[:<step>...]:<leaf>`. */
@@ -100,9 +101,9 @@
{/each}
{/if}
</div>
{#if resolved}
{@const { containingFlowPath, module } = resolved}
<div class="flex items-center gap-1 shrink-0">
<div class="flex items-center gap-1 shrink-0">
{#if resolved}
{@const { containingFlowPath, module } = resolved}
{#if $flowEditorDrawer}
<Button
unifiedSize="sm"
@@ -124,8 +125,9 @@
)}${module ? `&selected=${encodeURIComponent(leafId)}` : ''}`}
target="_blank"
/>
</div>
{/if}
{/if}
<FlowPanelChrome />
</div>
</div>
<div class="min-h-0 grow overflow-auto">
{#if loaded == undefined}
@@ -12,7 +12,7 @@
}
}
let { noEditor, branch = $bindable() }: Props = $props()
let { noEditor, branch }: Props = $props()
</script>
<div class="h-full flex flex-col">
@@ -23,13 +23,8 @@
</div>
{/snippet}
<div class="p-4">
<div class="mt-2 mb-2 text-sm font-bold">Skip failures</div>
<Toggle
bind:checked={branch.skip_failure}
options={{
right: 'Skip failures'
}}
/>
<div class="mb-2 text-xs font-semibold text-emphasis">Skip failures</div>
<Toggle bind:checked={branch.skip_failure} />
</div>
</FlowCard>
</div>
@@ -16,13 +16,7 @@
enableAi?: boolean
}
let {
branch = $bindable(),
parentModule,
previousModule,
noEditor,
enableAi = false
}: Props = $props()
let { branch, parentModule, previousModule, noEditor, enableAi = false }: Props = $props()
</script>
<div class="h-full flex flex-col">
@@ -32,19 +26,8 @@
<input bind:value={branch.summary} placeholder={'Summary'} />
</div>
{/snippet}
<div class="overflow-hidden flex-grow">
<h3 class="p-2">Predicate expression</h3>
<BranchPredicateEditor
{branch}
{parentModule}
{previousModule}
{enableAi}
on:updateSummary={(e) => {
if (!branch.summary) {
branch.summary = e.detail
}
}}
/>
<div class="flex h-full min-h-0 flex-col overflow-auto p-4" style="scrollbar-gutter: stable">
<BranchPredicateEditor {branch} {parentModule} {previousModule} {enableAi} />
</div>
</FlowCard>
</div>
@@ -1,20 +1,26 @@
<script lang="ts">
import { Alert, Badge, Tab } from '$lib/components/common'
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
import { Badge, Tab, Tabs } from '$lib/components/common'
import { GripVertical, Plus, Trash2 } from 'lucide-svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import {
addBranch as addBranchOp,
removeBranch as removeBranchOp,
reorderBranches as reorderBranchesOp,
graphBranchIndex
} from '../branchOps'
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
import { randomUUID } from '$lib/utils/uuid'
import Toggle from '$lib/components/Toggle.svelte'
import StepSettingsBadges from './StepSettingsBadges.svelte'
import { tick, untrack } from 'svelte'
import type { BranchAll, FlowModule } from '$lib/gen'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SplitPanesWrapper from '../../splitPanes/SplitPanesWrapper.svelte'
import FlowCard from '../common/FlowCard.svelte'
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
import FlowModuleSleep from './FlowModuleSleep.svelte'
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
import FlowModuleMock from './FlowModuleMock.svelte'
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
import { enterpriseLicense } from '$lib/stores'
import FlowModuleSkip from './FlowModuleSkip.svelte'
import TabsV2 from '$lib/components/common/tabs/TabsV2.svelte'
import FlowRunSettings from './FlowRunSettings.svelte'
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
interface Props {
@@ -31,110 +37,193 @@
value = flowModule.value as BranchAll
})
let selected = $state('early-stop')
// dnd needs a stable id per item; branches have none and must not gain one (it would
// land in the saved flow), so ids are held beside them, keyed by object identity.
const branchIds = new WeakMap<object, string>()
function idFor(branch: object): string {
let id = branchIds.get(branch)
if (!id) {
id = randomUUID()
branchIds.set(branch, id)
}
return id
}
let items = $state(value.branches.map((b) => ({ id: idFor(b), branch: b })))
// dnd owns `items` for the length of a gesture: mid-drag it holds a shadow placeholder
// alongside the real entries, so rebuilding from `value.branches` there would splice a
// second copy of the dragged branch into the list (duplicate keys).
let dragging = false
$effect(() => {
const next = value.branches.map((b) => ({ id: idFor(b), branch: b }))
// untrack: this reads and writes `items`, which would otherwise re-invalidate itself.
untrack(() => {
if (dragging) return
const same = next.length === items.length && next.every((it, i) => it.id === items[i].id)
if (!same) items = next
})
})
function handleConsider(e: CustomEvent<{ items: typeof items }>) {
dragging = true
items = e.detail.items
}
function handleFinalize(e: CustomEvent<{ items: typeof items }>) {
items = e.detail.items
reorderBranchesOp(
flowModule.id,
items.map((it) => it.branch),
{ flowStore, history }
)
dragging = false
}
const { flowStore, flowStateStore, history } = getContext<FlowEditorContext>('FlowEditorContext')
function addBranch() {
addBranchOp(flowModule.id, { flowStore, history })
refreshFlowStateStore(flowStore)
}
function removeBranch(arrayIndex: number) {
// The shared op counts branches the way the graph does; see graphBranchIndex.
removeBranchOp(flowModule.id, graphBranchIndex(value.type, arrayIndex), {
flowStore,
flowStateStore,
history
})
refreshFlowStateStore(flowStore)
}
let runSettings: FlowRunSettings | undefined = $state(undefined)
let selectedTab = $state('branches')
useUiIntent(`branchall-${flowModule.id}`, {
openTab: (tab) => {
selected = tab
openTab: async (tab) => {
// Every setting the intent can name lives in the other tab, which only mounts
// `runSettings` once selected.
selectedTab = 'settings'
await tick()
runSettings?.openSetting(tab)
}
})
</script>
<div class="h-full flex flex-col w-full" id="flow-editor-branch-all-wrapper">
<FlowCard {noEditor} title={value.type == 'branchall' ? 'Run all branches' : 'Run one branch'}>
<SplitPanesWrapper>
<Splitpanes horizontal>
<Pane size={flowModule ? 60 : 100}>
{#if !noEditor}
<Alert
type="info"
title="All branches will be run"
tooltip="Branch all"
documentationLink="https://www.windmill.dev/docs/flows/flow_branches#branch-all"
class="m-4"
>
The result of this step is the list of the result of each branch.
</Alert>
{/if}
<div class="p-4 mt-4 w-full">
<h3 class="mb-4"
>{value.branches.length} branch{value.branches.length > 1 ? 'es' : ''}</h3
>
<div class="flex flex-col gap-y-4 py-2 w-full">
{#each value.branches as branch, i}
<div class="flex flex-row gap-x-4 w-full items-center">
<div class="grow flex gap-2">
<Badge large={true} color="blue">Branch {i + 1}</Badge>
<input type="text" bind:value={branch.summary} placeholder="Summary" />
</div>
<div class="w-min-sm">
<Toggle
bind:checked={branch.skip_failure}
options={{
right: 'Skip failure'
}}
<FlowCard
{noEditor}
title={value.type == 'branchall' ? 'Run all branches' : 'Run one branch'}
subtitle="Every branch runs. The result of this step is the list of each branch's result."
subtitleDocLink="https://www.windmill.dev/docs/flows/flow_branches#branch-all"
>
<div class="flex h-full min-h-0 flex-col">
<Tabs bind:selected={selectedTab} wrapperClass="shrink-0">
<Tab value="branches" label="Branches" />
<Tab value="settings" label="Run settings">
{#snippet extra()}
<StepSettingsBadges {flowModule} />
{/snippet}
</Tab>
</Tabs>
<div
class="flex min-h-0 flex-1 flex-col gap-6 overflow-auto p-4"
style="scrollbar-gutter: stable"
>
{#if selectedTab === 'branches'}
<section class="flex w-full flex-col gap-4">
<div>
<section
class="flex flex-col gap-3"
use:dragHandleZone={{ items, flipDurationMs: 150, dropTargetStyle: {} }}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each items as item, i (item.id)}
<!-- The handle and the delete button each own a column, so the row below
lines up with the summary instead of running under them. -->
<div
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2 gap-y-0 rounded-md bg-surface-tertiary p-3 shadow-sm"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="cursor-move text-tertiary hover:text-primary"
use:dragHandle
aria-label="Reorder branch"
>
<GripVertical size={16} />
</div>
<div class="flex min-w-0 items-center gap-2">
<Badge color="blue" class="text-xs">Branch {i + 1}</Badge>
<TextInput
size="sm"
class="grow"
bind:value={
() => item.branch.summary ?? '', (v) => (item.branch.summary = String(v))
}
inputProps={{ placeholder: 'Summary' }}
/>
</div>
<Button
unifiedSize="sm"
variant="subtle"
destructive
iconOnly
startIcon={{ icon: Trash2 }}
title="Delete branch"
on:click={() => removeBranch(i)}
/>
<div class="col-start-2 py-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
bind:checked={item.branch.skip_failure}
options={{
right: 'Skip failure'
}}
/>
</div>
</div>
</div>
{/each}
{/each}
</section>
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Plus }}
wrapperClasses="mt-4 self-start"
on:click={addBranch}
>
Add branch
</Button>
</div>
<p class="text-sm">Add branches and steps directly on the graph.</p>
<div class="mt-6 mb-2 text-sm font-bold">Run in parallel</div>
<Toggle
bind:checked={value.parallel}
options={{
right: 'All branches run in parallel'
}}
/>
</div>
</Pane>
{#if flowModule}
<Pane size={40}>
<TabsV2 bind:selected>
<Tab value="early-stop" label="Early Stop/Break" />
<Tab value="skip" label="Skip" />
<Tab value="suspend" label="Suspend/Approval/Prompt" />
<Tab value="sleep" label="Sleep" />
<Tab value="mock" label="Mock" />
<Tab value="lifetime" label="Lifetime" />
{#snippet content()}
<div class="overflow-hidden bg-surface">
<TabContent value="early-stop" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleEarlyStop bind:flowModule />
</div>
</TabContent>
<TabContent value="skip" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSkip bind:flowModule {parentModule} {previousModule} />
</div>
</TabContent>
<TabContent value="suspend" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSuspend previousModuleId={previousModule?.id} bind:flowModule />
</div>
</TabContent>
<TabContent value="sleep" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule />
</div>
</TabContent>
<TabContent value="mock" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleMock bind:flowModule />
</div>
</TabContent>
<TabContent value="lifetime" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleDeleteAfterUse bind:flowModule disabled={!$enterpriseLicense} />
</div>
</TabContent>
</div>
{/snippet}
</TabsV2>
</Pane>
<div>
<label
for="branchall-parallel-{flowModule.id}"
class="mb-2 block w-fit cursor-pointer text-xs font-semibold text-emphasis"
>
Run in parallel
</label>
<Toggle
id="branchall-parallel-{flowModule.id}"
bind:checked={value.parallel}
options={{
right: 'All branches run in parallel'
}}
/>
</div>
</section>
{:else}
<FlowRunSettings
embedded
loopSubset
bind:this={runSettings}
bind:flowModule
{parentModule}
{previousModule}
selectedId={flowModule.id}
/>
{/if}
</Splitpanes>
</SplitPanesWrapper>
</div>
</div>
</FlowCard>
</div>
@@ -1,24 +1,29 @@
<script lang="ts">
import { Alert, Badge, Tab } from '$lib/components/common'
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
import TabsV2 from '$lib/components/common/tabs/TabsV2.svelte'
import { Badge, Tab, Tabs } from '$lib/components/common'
import { GripVertical, Plus, Trash2 } from 'lucide-svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import {
addBranch as addBranchOp,
removeBranch as removeBranchOp,
reorderBranches as reorderBranchesOp,
graphBranchIndex
} from '../branchOps'
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
import { randomUUID } from '$lib/utils/uuid'
import StepSettingsBadges from './StepSettingsBadges.svelte'
import { tick, untrack } from 'svelte'
import type { BranchOne, FlowModule } from '$lib/gen'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import FlowCard from '../common/FlowCard.svelte'
import BranchPredicateEditor from './BranchPredicateEditor.svelte'
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
import FlowModuleSleep from './FlowModuleSleep.svelte'
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
import SplitPanesWrapper from '../../splitPanes/SplitPanesWrapper.svelte'
import FlowModuleMock from './FlowModuleMock.svelte'
import { enterpriseLicense } from '$lib/stores'
import FlowModuleSkip from './FlowModuleSkip.svelte'
import FlowRunSettings from './FlowRunSettings.svelte'
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
interface Props {
// import FlowRetries from './FlowRetries.svelte'
flowModule: FlowModule
previousModule: FlowModule | undefined
parentModule: FlowModule | undefined
@@ -39,120 +44,180 @@
value = flowModule.value as BranchOne
})
let selected = $state('early-stop')
// dnd needs a stable id per item; branches have none and must not gain one (it would
// land in the saved flow), so ids are held beside them, keyed by object identity.
const branchIds = new WeakMap<object, string>()
function idFor(branch: object): string {
let id = branchIds.get(branch)
if (!id) {
id = randomUUID()
branchIds.set(branch, id)
}
return id
}
let items = $state(value.branches.map((b) => ({ id: idFor(b), branch: b })))
// dnd owns `items` for the length of a gesture: mid-drag it holds a shadow placeholder
// alongside the real entries, so rebuilding from `value.branches` there would splice a
// second copy of the dragged branch into the list (duplicate keys).
let dragging = false
$effect(() => {
const next = value.branches.map((b) => ({ id: idFor(b), branch: b }))
// untrack: this reads and writes `items`, which would otherwise re-invalidate itself.
untrack(() => {
if (dragging) return
const same = next.length === items.length && next.every((it, i) => it.id === items[i].id)
if (!same) items = next
})
})
function handleConsider(e: CustomEvent<{ items: typeof items }>) {
dragging = true
items = e.detail.items
}
function handleFinalize(e: CustomEvent<{ items: typeof items }>) {
items = e.detail.items
reorderBranchesOp(
flowModule.id,
items.map((it) => it.branch),
{ flowStore, history }
)
dragging = false
}
const { flowStore, flowStateStore, history } = getContext<FlowEditorContext>('FlowEditorContext')
function addBranch() {
addBranchOp(flowModule.id, { flowStore, history })
refreshFlowStateStore(flowStore)
}
function removeBranch(arrayIndex: number) {
// The shared op counts branches the way the graph does; see graphBranchIndex.
removeBranchOp(flowModule.id, graphBranchIndex(value.type, arrayIndex), {
flowStore,
flowStateStore,
history
})
refreshFlowStateStore(flowStore)
}
let runSettings: FlowRunSettings | undefined = $state(undefined)
let selectedTab = $state('branches')
useUiIntent(`branchone-${flowModule.id}`, {
openTab: (tab) => {
selected = tab
openTab: async (tab) => {
// Every setting the intent can name lives in the other tab, which only mounts
// `runSettings` once selected.
selectedTab = 'settings'
await tick()
runSettings?.openSetting(tab)
}
})
</script>
<div class="h-full" id="flow-editor-branch-one-wrapper">
<FlowCard {noEditor} title="Run one branch">
<SplitPanesWrapper>
<Splitpanes horizontal>
<Pane size={flowModule ? 60 : 100}>
{#if !noEditor}
<Alert
type="info"
title="Only first branch whose condition is true will be run"
tooltip="Branch one"
documentationLink="https://www.windmill.dev/docs/flows/flow_branches#branch-one"
class="m-4"
>
The result of this step is the result of the branch.
</Alert>
{/if}
<div class="p-4">
<h3 class="my-4">
{value.branches.length + 1} branch{value.branches.length + 1 > 1 ? 'es' : ''}
</h3>
<div class="py-2">
<div class="flex flex-row gap-2 text-sm p-2">
<Badge large={true} color="blue">Default branch</Badge>
<p class="italic text-primary"
>If none of the predicates' expressions evaluated in-order match, this branch is
chosen</p
>
</div>
{#each value.branches as branch, i}
<div class="flex flex-col gap-x-2 items-center">
<div class="w-full flex gap-2 px-2 pt-4 pb-2">
<Badge large={true} color="blue">Branch {i + 1}</Badge>
<input
class="w-full"
type="text"
bind:value={branch.summary}
placeholder="Summary"
/>
</div>
<div class="w-full border">
<BranchPredicateEditor
{branch}
on:updateSummary={(e) => {
if (!branch.summary) {
branch.summary = e.detail
<FlowCard
{noEditor}
title="Run one branch"
subtitle="The first branch whose predicate is true runs. The result of this step is that branch's result."
subtitleDocLink="https://www.windmill.dev/docs/flows/flow_branches#branch-one"
>
<div class="flex h-full min-h-0 flex-col">
<Tabs bind:selected={selectedTab} wrapperClass="shrink-0">
<Tab value="branches" label="Branches" />
<Tab value="settings" label="Run settings">
{#snippet extra()}
<StepSettingsBadges {flowModule} />
{/snippet}
</Tab>
</Tabs>
<div
class="flex min-h-0 flex-1 flex-col gap-6 overflow-auto p-4"
style="scrollbar-gutter: stable"
>
{#if selectedTab === 'branches'}
<section>
<div class="flex flex-col gap-3">
<section
class="flex flex-col gap-3"
use:dragHandleZone={{ items, flipDurationMs: 150, dropTargetStyle: {} }}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each items as item, i (item.id)}
<!-- The handle and the delete button each own a column, so the predicate
below lines up with the summary instead of running under them. -->
<div
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2 gap-y-0 rounded-md bg-surface-tertiary p-3 shadow-sm"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="cursor-move text-tertiary hover:text-primary"
use:dragHandle
aria-label="Reorder branch"
>
<GripVertical size={16} />
</div>
<div class="flex min-w-0 items-center gap-2">
<Badge color="blue" class="text-xs">Branch {i + 1}</Badge>
<TextInput
size="sm"
class="grow"
bind:value={
() => item.branch.summary ?? '', (v) => (item.branch.summary = String(v))
}
}}
parentModule={flowModule}
{previousModule}
{enableAi}
inputProps={{ placeholder: 'Summary' }}
/>
</div>
<Button
unifiedSize="sm"
variant="subtle"
destructive
iconOnly
startIcon={{ icon: Trash2 }}
title="Delete branch"
on:click={() => removeBranch(i)}
/>
<div class="col-start-2 py-2">
<BranchPredicateEditor
branch={item.branch}
parentModule={flowModule}
{previousModule}
{enableAi}
/>
</div>
</div>
</div>
{/each}
{/each}
</section>
<div class="flex items-center gap-2 rounded-md bg-surface-tertiary p-3 shadow-sm">
<Badge color="blue" class="text-xs">Default</Badge>
<p class="text-xs italic text-tertiary">Runs if none of the above match</p>
</div>
</div>
<p class="text-sm">Add branches and steps directly on the graph.</p>
</div>
</Pane>
{#if flowModule}
<Pane size={40}>
<TabsV2 bind:selected>
<Tab value="early-stop" label="Early Stop/Break" />
<Tab value="skip" label="Skip" />
<Tab value="suspend" label="Suspend/Approval/Prompt" />
<Tab value="sleep" label="Sleep" />
<Tab value="mock" label="Mock" />
<Tab value="lifetime" label="Lifetime" />
{#snippet content()}
<div class="overflow-hidden bg-surface">
<TabContent value="early-stop" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleEarlyStop bind:flowModule />
</div>
</TabContent>
<TabContent value="skip" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSkip bind:flowModule {parentModule} {previousModule} />
</div>
</TabContent>
<TabContent value="suspend" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSuspend previousModuleId={previousModule?.id} bind:flowModule />
</div>
</TabContent>
<TabContent value="sleep" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule />
</div>
</TabContent>
<TabContent value="mock" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleMock bind:flowModule />
</div>
</TabContent>
<TabContent value="lifetime" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleDeleteAfterUse bind:flowModule disabled={!$enterpriseLicense} />
</div>
</TabContent>
</div>
{/snippet}
</TabsV2>
</Pane>
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Plus }}
wrapperClasses="mt-4 self-start"
on:click={addBranch}
>
Add branch
</Button>
</section>
{:else}
<FlowRunSettings
embedded
loopSubset
bind:this={runSettings}
bind:flowModule
{parentModule}
{previousModule}
selectedId={flowModule.id}
/>
{/if}
</Splitpanes>
</SplitPanesWrapper>
</div>
</div>
</FlowCard>
</div>
@@ -2,6 +2,7 @@
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import FlowModuleWrapper from './FlowModuleWrapper.svelte'
import { moduleSlot, savedModuleById } from '../moduleSlot'
import FlowSettings from './FlowSettings.svelte'
import FlowInput from './FlowInput.svelte'
import FlowFailureModule from './FlowFailureModule.svelte'
@@ -124,7 +125,7 @@
{noEditor}
disabled={disabledFlowInputs}
on:openTriggers={(ev) => {
selectionManager.selectId('Trigger')
selectionManager.selectId('Trigger', { openPanel: true })
handleSelectTriggerFromKind(triggersState, triggersCount, savedFlow?.path, ev.detail.kind)
showCaptureHint.set(true)
}}
@@ -188,12 +189,13 @@
{:else}
{#key selectedId}
{#each flowStore.val.value.modules as flowModule, index (flowModule.id ?? index)}
{@const slot = moduleSlot(() => flowStore.val.value.modules, flowModule.id, flowModule)}
<FlowModuleWrapper
{noEditor}
bind:flowModule={flowStore.val.value.modules[index]}
bind:flowModule={slot.get, slot.set}
previousModule={flowStore.val.value.modules[index - 1]}
{enableAi}
savedModule={savedFlow?.value.modules[index]}
savedModule={savedModuleById(savedFlow?.value.modules, flowModule.id)}
{forceTestTab}
{highlightArg}
/>
@@ -234,6 +234,11 @@
connectProp: () => {},
propPickerConfig: writable(undefined),
clearConnect: () => {},
pickerMode: () => 'popover' as const,
pickableProperties: () => undefined,
result: () => undefined,
extraResults: () => undefined,
onPick: () => {},
exprBeingEdited: writable([])
})
</script>
@@ -1,37 +1,28 @@
<script lang="ts">
import { getContext } from 'svelte'
import { getContext, tick } from 'svelte'
import FlowCard from '../common/FlowCard.svelte'
import type { FlowEditorContext } from '../types'
import Toggle from '$lib/components/Toggle.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
// import FlowRetries from './FlowRetries.svelte'
import { Button, Drawer, Tab, TabContent } from '$lib/components/common'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { Button, Drawer, Tab, Tabs } from '$lib/components/common'
import { getStepPropPicker } from '../previousResults'
import { enterpriseLicense } from '$lib/stores'
import FlowModuleSleep from './FlowModuleSleep.svelte'
import FlowModuleMock from './FlowModuleMock.svelte'
import { Play, FunctionSquare } from 'lucide-svelte'
import { Play } from 'lucide-svelte'
import type { FlowModule, ForloopFlow, Job } from '$lib/gen'
import FlowLoopIterationPreview from '$lib/components/FlowLoopIterationPreview.svelte'
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
import IteratorGen from '$lib/components/copilot/IteratorGen.svelte'
import FlowModuleSkip from './FlowModuleSkip.svelte'
import FlowPlugConnect from '$lib/components/FlowPlugConnect.svelte'
import FlowRunSettings from './FlowRunSettings.svelte'
import StepSettingsBadges from './StepSettingsBadges.svelte'
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
import type { PropPickerContext } from '$lib/components/prop_picker'
import TabsV2 from '$lib/components/common/tabs/TabsV2.svelte'
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
import { emptySchema } from '$lib/utils'
import { slide } from 'svelte/transition'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { emptySchema, emptyString } from '$lib/utils'
import { slideDynamic } from '$lib/transitions'
const { previewArgs, flowStateStore, flowStore, currentEditor } =
getContext<FlowEditorContext>('FlowEditorContext')
@@ -54,20 +45,21 @@
let editor: SimpleEditor | undefined = $state(undefined)
let parallelismEditor: SimpleEditor | undefined = $state(undefined)
let selected: string = $state('early-stop')
let parallelismType: 'static' | 'javascript' | undefined = $state(
mod.value.type === 'forloopflow'
? mod.value.parallelism?.type === 'javascript'
? 'javascript'
: 'static'
: undefined
)
let runSettings: FlowRunSettings | undefined = $state(undefined)
let parallelismSchema = $state(emptySchema())
parallelismSchema.properties['parallelism'] = {
type: 'number'
type: 'integer'
}
// `array` keeps the field on the expression editor: an iterator is never a literal
// the static input could hold, which is also why the type switch is hidden.
let iteratorSchema = $state(emptySchema())
iteratorSchema.properties['iterator'] = {
type: 'array'
}
iteratorSchema.required = ['iterator']
if (mod.value.type === 'forloopflow') {
const forloopValue = mod.value as ForloopFlow
if (typeof forloopValue.parallelism === 'number') {
@@ -78,14 +70,20 @@
}
}
// UI Intent handling for AI tool control
let selectedTab = $state('loop')
useUiIntent(`forloopflow-${mod.id}`, {
openTab: (tab) => {
selected = tab
openTab: async (tab) => {
// Every setting the intent can name lives in the other tab, which only mounts
// `runSettings` once selected.
selectedTab = 'settings'
await tick()
runSettings?.openSetting(tab)
}
})
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
const propPickerContext = getContext<PropPickerContext>('PropPickerContext')
const { flowPropPickerConfig } = propPickerContext
flowPropPickerConfig.set(undefined)
let stepPropPicker = $derived(
@@ -125,8 +123,53 @@
})
let suggestion: string | undefined = $state(undefined)
// A loop with nothing to iterate over fails at runtime, and the step's own inputs
// aren't schema-checked like a script's — so the field says it here.
const iterator = $derived(
mod.value.type === 'forloopflow' ? (mod.value as ForloopFlow).iterator : undefined
)
const iteratorMissing = $derived.by(() => {
if (iterator == undefined) return true
if (iterator.type === 'javascript') return emptyString(iterator.expr)
if (iterator.type === 'static') return iterator.value == undefined
return false
})
const ITERATOR_LABEL = 'Iterator expression'
const ITERATOR_MISSING = 'An iterator expression is required for the loop to run.'
const ITERATOR_TOOLTIP =
'The JavaScript expression that will be evaluated to get the list of items to iterate over. Example: ["banana", "apple", flow_input.my_fruit].'
const DEFAULT_PARALLELISM = 4
const PARALLELISM_LABEL = 'Limit concurrent iterations'
const SQUASH_PARALLEL_CONFLICT =
'Squash and Run in parallel are mutually exclusive: squashing runs every iteration in sequence on a single worker. Turn the other one off to use this.'
const PARALLELISM_TOOLTIP =
'Cap how many iterations run at once, so a huge loop does not flood the workers. Without a cap every iteration starts at once.'
const parallelismCapped = $derived(
mod.value.type === 'forloopflow' && mod.value.parallelism != undefined
)
</script>
{#snippet parallelismToggle()}
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
checked={parallelismCapped}
on:change={({ detail }) => {
;(mod.value as ForloopFlow).parallelism = detail
? { type: 'static', value: DEFAULT_PARALLELISM }
: undefined
}}
options={{
right: PARALLELISM_LABEL,
rightTooltip: PARALLELISM_TOOLTIP,
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_loops'
}}
/>
{/snippet}
<Drawer bind:open={previewOpen} alwaysOpen size="75%">
<FlowLoopIterationPreview
modules={mod.value.type == 'forloopflow' ? mod.value.modules : []}
@@ -143,7 +186,7 @@
<FlowCard {noEditor} title="For loop">
{#snippet header()}
<div class="grow">
<div class="my-2 flex flex-row gap-2 items-center">
<div class="flex flex-row gap-2 items-center">
<div>
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_loops">
Add steps inside the loop and specify an iterator expression that defines the sequence
@@ -157,7 +200,7 @@
<Button
on:click={() => (previewOpen = true)}
startIcon={{ icon: Play }}
variant="accent"
variant="default"
size="sm">Test an iteration</Button
>
</div>
@@ -165,332 +208,171 @@
</div>
{/snippet}
<Splitpanes horizontal class="h-full">
<Pane size={50} minSize={20} class="p-4">
{#if mod.value.type === 'forloopflow'}
<div class="flex flex-row gap-6 mt-2 mb-6">
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold"
>Skip failures <Tooltip
documentationLink="https://www.windmill.dev/docs/flows/flow_loops"
>If disabled, the flow will fail as soon as one of the iteration fail. Otherwise,
the error will be collected as the result of the iteration. Regardless of this
setting, if a flow level error handler is defined, it will process the error.
(Workspace error handlers will NOT be used to process errors if enabled.)</Tooltip
></div
<div class="flex h-full min-h-0 flex-col">
{#if mod.value.type === 'forloopflow'}
<Tabs bind:selected={selectedTab} wrapperClass="shrink-0">
<Tab value="loop" label="Loop" />
<Tab value="settings" label="Run settings">
{#snippet extra()}
<StepSettingsBadges flowModule={mod} />
{/snippet}
</Tab>
</Tabs>
<div
class="flex min-h-0 flex-1 flex-col gap-8 overflow-auto p-4"
style="scrollbar-gutter: stable"
>
{#if selectedTab === 'loop'}
<section>
<PropPickerWrapper
popover={true}
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
displayContext={false}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<InputTransformForm
bind:arg={
() => (mod.value as ForloopFlow).iterator,
(v) => {
;(mod.value as ForloopFlow).iterator = v
}
}
argName="iterator"
label={ITERATOR_LABEL}
headerTooltip={ITERATOR_TOOLTIP}
error={iteratorMissing ? ITERATOR_MISSING : undefined}
schema={iteratorSchema}
noDynamicToggle
extraLib={stepPropPicker.extraLib}
pickableProperties={stepPropPicker.pickableProperties}
previousModuleId={previousModule?.id}
bind:suggestion
bind:focused={iteratorFieldFocused}
aiOnKeyUp={iteratorGen?.onKeyUp}
bind:editor
>
{#snippet aiGen()}
{#if enableAi}
<IteratorGen
bind:this={iteratorGen}
focused={iteratorFieldFocused}
arg={(mod.value as ForloopFlow).iterator}
on:showExpr={(e) => (suggestion = e.detail || undefined)}
on:setExpr={(e) => setExpr(e.detail)}
pickableProperties={stepPropPicker.pickableProperties}
/>
{/if}
{/snippet}
</InputTransformForm>
</PropPickerWrapper>
</section>
<section class="flex flex-col gap-6">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
bind:checked={mod.value.skip_failures}
options={{
right: 'Skip failures'
right: 'Skip failures',
rightTooltip:
'If disabled, the flow will fail as soon as one of the iteration fail. Otherwise, the error will be collected as the result of the iteration. Regardless of this setting, if a flow level error handler is defined, it will process the error. (Workspace error handlers will NOT be used to process errors if enabled.)',
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_loops'
}}
class="whitespace-nowrap"
/>
</div>
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold"
>Squash
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_loops">
Squashing a for loop runs all iterations on the same worker, using a single runner
per step for the entire loop. This eliminates cold starts between iterations for
supported languages (Bun, Deno, and Python).
</Tooltip>
</div>
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
bind:checked={mod.value.squash}
on:change={({ detail }) => {
;(mod.value as ForloopFlow).squash = detail
}}
options={{
right: 'Squash'
}}
class="whitespace-nowrap"
disabled={mod.value.parallel}
/>
</div>
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold">Run in parallel</div>
<Toggle
bind:checked={mod.value.parallel}
on:change={({ detail }) => {
if (detail === false) {
;(mod.value as ForloopFlow).parallelism = undefined
}
}}
options={{
right: 'All iterations run in parallel'
title: mod.value.parallel ? SQUASH_PARALLEL_CONFLICT : undefined,
right: 'Squash',
rightTooltip:
'Squashing a for loop runs all iterations on the same worker, using a single runner per step for the entire loop. This eliminates cold starts between iterations for supported languages (Bun, Deno, and Python).',
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_loops'
}}
class="whitespace-nowrap"
disabled={mod.value.squash}
/>
</div>
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold"
>Parallelism <Tooltip
>Assign a maximum number of branches run in parallel to control huge for-loops.</Tooltip
>
</div>
<div class="flex gap-2 items-center">
<input
type="number"
min="1"
class="w-20 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-surface"
disabled={!mod.value.parallel || parallelismType === 'javascript'}
placeholder={parallelismType === 'javascript' ? 'Expression' : ''}
bind:value={
() => {
const parallelismExpr = (mod.value as ForloopFlow).parallelism
return parallelismExpr && parallelismExpr.type === 'static'
? parallelismExpr.value
: ''
},
(value) => {
if (value === '' || value === null || value === undefined) {
;(mod.value as ForloopFlow).parallelism = undefined
} else {
;(mod.value as ForloopFlow).parallelism = {
type: 'static',
value
}
}
}
}
/>
<ToggleButtonGroup
disabled={!mod.value.parallel}
bind:selected={parallelismType}
on:selected={(e) => {
const forLoopFlow = mod.value as ForloopFlow
if (e.detail == parallelismType) return
if (e.detail === 'javascript') {
if (!forLoopFlow.parallelism || forLoopFlow.parallelism.type !== 'javascript') {
;(mod.value as ForloopFlow).parallelism = {
type: 'javascript',
expr: ''
}
}
} else {
if (!forLoopFlow.parallelism || forLoopFlow.parallelism.type !== 'static') {
;(mod.value as ForloopFlow).parallelism = {
type: 'static',
value: 0
}
}
}
<!-- Its own group: the setting's input belongs to the toggle above it, not
24px away like the next setting. -->
<div class="flex flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
bind:checked={mod.value.parallel}
on:change={({ detail }) => {
// An absent `parallelism` means "no cap" to the worker, so switching
// parallelism on must not seed one — the cap below is opted into.
if (!detail) (mod.value as ForloopFlow).parallelism = undefined
}}
disabled={mod.value.squash}
options={{
title: mod.value.squash ? SQUASH_PARALLEL_CONFLICT : undefined,
right: 'Run in parallel',
rightTooltip: 'Run the iterations concurrently instead of one after the other.',
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_loops'
}}
>
{#snippet children({ item })}
<ToggleButton small label="static" value="static" {item} />
<ToggleButton
small
tooltip="JavaScript expression ('flow_input' or 'results')."
value="javascript"
icon={FunctionSquare}
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
</div>
</div>
{#if mod.value.type === 'forloopflow' && mod.value.parallel && mod.value.parallelism?.type == 'javascript'}
<div class="my-2 flex flex-row gap-2 items-center">
<div class="text-sm font-bold whitespace-nowrap">
Parallelism expression
<Tooltip>
JavaScript expression that defines the maximum number of parallel executions.
Example: flow_input.max_parallel || 3
</Tooltip>
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="border rounded-md overflow-auto w-full mb-2 h-full max-h-[250px]"
id="flow-editor-parallel-expression"
transition:slide={{ duration: 300 }}
>
<PropPickerWrapper
notSelectable
noPadding
flow_input={stepPropPicker.pickableProperties.flow_input}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
parallelismEditor?.insertAtCursor(detail)
parallelismEditor?.focus()
}}
>
<SimpleEditor
bind:this={parallelismEditor}
autofocus
lang="javascript"
bind:code={
() => {
const parallelismExpr = (mod.value as ForloopFlow).parallelism
return parallelismExpr && parallelismExpr.type === 'javascript'
? parallelismExpr.expr
: ''
},
(expr) => {
;(mod.value as ForloopFlow).parallelism = {
type: 'javascript',
expr
}
}
}
class="h-full"
shouldBindKey={false}
extraLib={stepPropPicker.extraLib}
/>
</PropPickerWrapper>
</div>
{/if}
<div class="my-2 flex flex-row gap-2 items-center">
<div class="text-sm font-bold whitespace-nowrap">
Iterator expression
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_loops">
The JavaScript expression that will be evaluated to get the list of items to iterate
over. Example : ["banana", "apple", flow_input.my_fruit].
</Tooltip>
</div>
<FlowPlugConnect
connecting={$flowPropPickerConfig != undefined}
on:click={() => {
const config = {
onSelect: (code) => {
setExpr(code)
return true
},
clearFocus: () => {
flowPropPickerConfig.set(undefined)
}
}
flowPropPickerConfig.set({
...config,
clearFocus: () => {
flowPropPickerConfig.set(undefined)
}
})
}}
/>
{#if enableAi}
<IteratorGen
bind:this={iteratorGen}
focused={iteratorFieldFocused}
arg={mod.value.iterator}
on:showExpr={(e) => (suggestion = e.detail || undefined)}
on:setExpr={(e) => {
setExpr(e.detail)
}}
pickableProperties={stepPropPicker.pickableProperties}
/>
{/if}
</div>
{#if mod.value.parallel}
<div class="pl-9" transition:slideDynamic>
<PropPickerWrapper
popover={true}
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
displayContext={false}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
parallelismEditor?.insertAtCursor(detail)
parallelismEditor?.focus()
}}
>
<!-- Keyed on the toggle: the form seeds its static/expression mode once, so re-enabling
the cap would hand a fresh static value to a form still in expression mode. -->
{#key parallelismCapped}
<InputTransformForm
bind:arg={
() => (mod.value as ForloopFlow).parallelism,
(v) => {
;(mod.value as ForloopFlow).parallelism = v
}
}
argName="parallelism"
collapsed={!parallelismCapped}
header={parallelismToggle}
schema={parallelismSchema}
argExtra={{ min: 1, step: 1 }}
animateAppear
previousModuleId={previousModule?.id}
bind:editor={parallelismEditor}
/>
{/key}
</PropPickerWrapper>
</div>
{/if}
</div>
{#if mod.value.iterator.type == 'javascript'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="border rounded-md overflow-auto w-full"
id="flow-editor-iterator-expression"
onkeyup={iteratorGen?.onKeyUp}
>
<PropPickerWrapper
notSelectable
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
if ($flowPropPickerConfig) {
setExpr(detail)
flowPropPickerConfig.set(undefined)
return
}
editor?.insertAtCursor(detail)
editor?.focus()
}}
noPadding
>
<div class="relative w-full h-full overflow-clip">
<SimpleEditor
small
bind:this={editor}
on:focus={() => {
iteratorFieldFocused = true
}}
on:blur={() => {
iteratorFieldFocused = false
}}
lang="javascript"
bind:code={mod.value.iterator.expr}
class="h-full"
shouldBindKey={false}
extraLib={stepPropPicker.extraLib}
{suggestion}
/>
</div>
</PropPickerWrapper>
</div>
<FlowModuleEarlyStop blocks="stop-after" bind:flowModule={mod} />
</section>
{:else}
<Button
on:click={() => {
if (mod.value.type === 'forloopflow') mod.value.iterator.type = 'javascript'
}}
<FlowRunSettings
embedded
loopSubset
earlyStopBlocks="all-iters"
bind:this={runSettings}
bind:flowModule={mod}
{parentModule}
{previousModule}
selectedId={mod.id}
/>
{/if}
{/if}
</Pane>
<Pane size={40} minSize={20} class="flex flex-col flex-1">
<TabsV2 bind:selected>
<!-- <Tab value="retries">Retries</Tab> -->
<Tab value="early-stop" label="Early Stop/Break" />
<Tab value="skip" label="Skip" />
<Tab value="suspend" label="Suspend/Approval/Prompt" />
<Tab value="sleep" label="Sleep" />
<Tab value="mock" label="Mock" />
<Tab value="lifetime" label="Lifetime" />
{#snippet content()}
<div class="overflow-hidden bg-surface" style="height:calc(100% - 32px);">
<!-- <TabContent value="retries" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowRetries bind:flowModule={mod} />
</div>
</TabContent> -->
<TabContent value="early-stop" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleEarlyStop bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="skip" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSkip bind:flowModule={mod} {parentModule} {previousModule} />
</div>
</TabContent>
<TabContent value="suspend" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSuspend previousModuleId={previousModule?.id} bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="sleep" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="mock" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleMock bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="lifetime" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleDeleteAfterUse bind:flowModule={mod} disabled={!$enterpriseLicense} />
</div>
</TabContent>
</div>
{/snippet}
</TabsV2>
</Pane>
</Splitpanes>
</div>
{/if}
</div>
</FlowCard>
@@ -1,12 +1,12 @@
<script lang="ts">
import Label from '$lib/components/Label.svelte'
import Section from '$lib/components/Section.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import type { FlowModule } from '$lib/gen'
import { stepSettingDefaults } from '../flowStepSettings'
import { SecondsInput } from '../../common'
import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte'
import { slideDynamic } from '$lib/transitions'
interface Props {
flowModule: FlowModule
@@ -30,18 +30,12 @@
onEditWorkspaceScript
}: Props = $props()
let isCacheEnabled = $derived(Boolean(flowModule.cache_ttl))
// Presence, not truthiness: SecondsInput passes through 0 while a segment is being
// retyped, and reading that as off would disable the field mid-edit.
let isCacheEnabled = $derived(flowModule.cache_ttl !== undefined)
</script>
<Section label="Cache" class="flex flex-col gap-4">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/cache">
If defined, the result of the step will be cached for the number of seconds defined such that
if this step were to be re-triggered with the same input it would retrieve and return its
cached value instead of recomputing it.
</Tooltip>
{/snippet}
<div class="flex flex-col gap-2">
{#if flowModule.value.type == 'script'}
<WorkspaceScriptSettingInfo
label="Cache"
@@ -61,34 +55,41 @@
</p>
{:else}
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
checked={isCacheEnabled}
on:change={() => {
if (isCacheEnabled && flowModule.cache_ttl != undefined) {
if (isCacheEnabled) {
flowModule.cache_ttl = undefined
} else {
flowModule.cache_ttl = 600
flowModule.cache_ttl = stepSettingDefaults('cache')
}
}}
options={{
right: 'Cache the results for each possible inputs'
right: 'Cache results',
rightTooltip:
'The result of the step is cached for the configured number of seconds; a re-trigger with the same input returns the cached value instead of recomputing it.',
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/cache'
}}
/>
{#if flowModule.cache_ttl}
<Label label="How long to keep cache valid">
<SecondsInput bind:seconds={flowModule.cache_ttl} />
</Label>
<Toggle
size="2xs"
bind:checked={
() => flowModule.cache_ignore_s3_path,
(v) => (flowModule.cache_ignore_s3_path = v || undefined)
}
options={{
right: 'Ignore S3 Object paths for caching purposes',
rightTooltip:
'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.'
}}
/>
{#if isCacheEnabled}
<div class="flex flex-col gap-2 pl-9" transition:slideDynamic>
<Label label="How long to keep cache valid">
<SecondsInput bind:seconds={flowModule.cache_ttl} />
</Label>
<Toggle
size="2xs"
bind:checked={
() => flowModule.cache_ignore_s3_path,
(v) => (flowModule.cache_ignore_s3_path = v || undefined)
}
options={{
right: 'Ignore S3 object paths',
rightTooltip:
'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.'
}}
/>
</div>
{/if}
{/if}
</Section>
</div>
@@ -1,10 +1,8 @@
<script lang="ts">
import { createBubbler } from 'svelte/legacy'
const bubble = createBubbler()
import { Pane, Splitpanes } from 'svelte-splitpanes'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import StepSettingsBadges from './StepSettingsBadges.svelte'
import Editor from '$lib/components/Editor.svelte'
import EditorBar, {
EDITOR_BAR_WIDTH_THRESHOLD,
@@ -22,47 +20,27 @@
import { getContext, onDestroy, tick, untrack } from 'svelte'
import type { FlowEditorContext, FlowGraphAssetContext } from '../types'
import FlowModuleScript from './FlowModuleScript.svelte'
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
import FlowModuleCache from './FlowModuleCache.svelte'
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
import FlowRetries from './FlowRetries.svelte'
import FlowRunSettings from './FlowRunSettings.svelte'
import { getFailureStepPropPicker, getStepPropPicker } from '../previousResults'
import { deepEqual } from 'fast-equals'
import Section from '$lib/components/Section.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import FlowModuleSleep from './FlowModuleSleep.svelte'
import FlowPathViewer from './FlowPathViewer.svelte'
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
import AgentResourceBar from './AgentResourceBar.svelte'
import AgentToolBindings from './AgentToolBindings.svelte'
import { getLinkedAgentTools, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
import { flowLocalAgentSchema } from '../agentResourceUtils'
import FlowModuleMockTransitionMessage from './FlowModuleMockTransitionMessage.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { SecondsInput } from '$lib/components/common'
import DiffEditor from '$lib/components/DiffEditor.svelte'
import type { ButtonProp } from '$lib/components/diffEditorTypes'
import FlowModuleTimeout from './FlowModuleTimeout.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import s3Scripts from './s3Scripts/lib'
import Label from '$lib/components/Label.svelte'
import { enterpriseLicense } from '$lib/stores'
import { isCloudHosted } from '$lib/cloud'
import { loadSchemaFromModule } from '../flowInfers'
import FlowModuleSkip from './FlowModuleSkip.svelte'
import FlowModuleDebounce from './FlowModuleDebounce.svelte'
import { type Job } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { checkIfParentLoop } from '../utils.svelte'
import { useWorkspaceScriptSettings } from '../useWorkspaceScriptSettings.svelte'
import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte'
import { getActiveScriptSettingsBadges } from '$lib/components/scriptSettings'
import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte'
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
import LogViewer from '$lib/components/LogViewer.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
@@ -200,10 +178,8 @@
flowModule.value.type === 'aiagent'
)
let visibleSelected = $derived(selected === 'chat' && !canShowChatTab ? 'inputs' : selected)
let runSettings: FlowRunSettings | undefined = $state()
let agentLinked = $derived(flowModule.value.type === 'aiagent' && Boolean(flowModule.value.agent))
let advancedSelected = $state('retries')
let advancedRuntimeSelected = $state('concurrency')
let s3Kind = $state('s3_client')
let validCode = $state(true)
let width = $state(1200)
let testJob: Job | undefined = $state(undefined)
@@ -334,7 +310,7 @@
function selectAdvanced(subtab: string) {
selected = 'advanced'
advancedSelected = subtab
tick().then(() => runSettings?.openSetting(subtab))
}
function setOmitOutputFromConversation(omit: boolean) {
@@ -351,7 +327,7 @@
let forceReload = $state(0)
let editorPanelSize = $state(
untrack(() => noEditor) ? 0 : flowModule.value.type == 'script' ? 30 : 50
untrack(() => noEditor) ? 0 : flowModule.value.type == 'script' ? 30 : 40
)
let editorSettingsPanelSize = $state(100 - untrack(() => editorPanelSize))
let stepHistoryLoader = getStepHistoryLoaderContext()
@@ -362,6 +338,25 @@
}
}
// Reached from both headers: the card header owns the script-path actions, the module
// header the subflow ones.
async function reloadModule() {
if (flowModule.value.type == 'script') {
if (flowModule.value.hash != undefined) {
flowModule.value.hash = await getLatestHashForScript(flowModule.value.path, opWs)
}
forceReload++
// Keep the surfaced concurrency/cache values and badges in sync after a
// settings/code save from the header (path/hash may be unchanged).
await referencedScriptSettings.reload()
await reload(flowModule)
}
if (flowModule.value.type == 'flow') {
forceReload++
await reload(flowModule)
}
}
let leftPanelSize = $state(0)
function showDiffMode() {
@@ -810,16 +805,18 @@
<div class="h-full bg-surface" bind:clientWidth={width}>
<FlowCard
flowModuleValue={flowModule?.value}
on:reload={() => {
forceReload++
reload(flowModule)
}}
{noEditor}
on:setHash={(e) => {
if (flowModule.value.type == 'script') {
flowModule.value.hash = e.detail
}
}}
on:fork={async () => {
const [module, state] = await fork(flowModule, opWs)
flowModule = module
flowStateStore.val[module.id] = state
}}
on:reload={reloadModule}
bind:summary={flowModule.summary}
bind:description={toolDescription}
{isAgentTool}
@@ -837,35 +834,7 @@
flowModule.value.tag = e.detail
}
}}
on:toggleSuspend={() => selectAdvanced('suspend')}
on:toggleSleep={() => selectAdvanced('sleep')}
on:toggleMock={() => selectAdvanced('mock')}
on:toggleRetry={() => selectAdvanced('retries')}
on:togglePin={() => (selected = 'test')}
on:toggleConcurrency={() => selectAdvanced('runtime')}
on:toggleCache={() => selectAdvanced('cache')}
on:toggleStopAfterIf={() => selectAdvanced('early-stop')}
on:fork={async () => {
const [module, state] = await fork(flowModule, opWs)
flowModule = module
flowStateStore.val[module.id] = state
}}
on:reload={async () => {
if (flowModule.value.type == 'script') {
if (flowModule.value.hash != undefined) {
flowModule.value.hash = await getLatestHashForScript(flowModule.value.path, opWs)
}
forceReload++
// Keep the surfaced concurrency/cache values and badges in sync after
// a settings/code save from the header (path/hash may be unchanged).
await referencedScriptSettings.reload()
await reload(flowModule)
}
if (flowModule.value.type == 'flow') {
forceReload++
await reload(flowModule)
}
}}
on:reload={reloadModule}
on:createScriptFromInlineScript={async () => {
const [module, state] = await createScriptFromInlineScript(
flowModule,
@@ -1131,7 +1100,11 @@
/>
{/if}
{#if !preprocessorModule && !isAgentTool}
<Tab value="advanced" label="Advanced" />
<Tab value="advanced" label="Run settings">
{#snippet extra()}
<StepSettingsBadges {flowModule} />
{/snippet}
</Tab>
{/if}
</Tabs>
{#if visibleSelected === 'inputs' && (flowModule.value.type == 'rawscript' || flowModule.value.type == 'script' || flowModule.value.type == 'flow' || flowModule.value.type == 'aiagent')}
@@ -1312,316 +1285,24 @@
</Section>
</div>
{:else if visibleSelected === 'advanced'}
<Tabs bind:selected={advancedSelected} wrapperClass="shrink-0">
<Tab
value="retries"
active={flowModule.retry !== undefined || flowModule.continue_on_error}
label="Error handling"
/>
{#if !selectedId.includes('failure')}
<Tab value="runtime" label="Runtime" />
<Tab value="cache" active={Boolean(flowModule.cache_ttl)} label="Cache" />
<Tab
value="early-stop"
active={Boolean(
flowModule.stop_after_if || flowModule.stop_after_all_iters_if
)}
label="Early Stop"
/>
<Tab value="skip" active={Boolean(flowModule.skip_if)} label="Skip" />
<Tab value="suspend" active={Boolean(flowModule.suspend)} label="Suspend" />
<Tab value="sleep" active={Boolean(flowModule.sleep)} label="Sleep" />
<Tab
value="debounce"
active={Boolean(flowModule.debouncing?.debounce_delay_s)}
label="Debounce"
/>
<Tab value="mock" active={Boolean(flowModule.mock?.enabled)} label="Mock" />
<Tab value="same_worker" label="Shared Directory" />
{#if flowModule.value['language'] === 'python3' || flowModule.value['language'] === 'deno'}
<Tab value="s3" label="S3" />
{/if}
{/if}
</Tabs>
{#if advancedSelected === 'runtime'}
<Tabs bind:selected={advancedRuntimeSelected} wrapperClass="shrink-0">
<Tab value="concurrency" label="Concurrency" />
<Tab value="timeout" label="Timeout" />
<Tab value="priority" label="Priority" />
<Tab value="lifetime" label="Lifetime" />
</Tabs>
{/if}
<div class="flex-1 overflow-auto p-4">
{#if advancedSelected === 'retries'}
<Section label="Continue on error">
{#snippet header()}
<Tooltip>
When enabled, the flow will continue to the next step even if this
step fails (after exhausting all retries, if any). This enables to
process the error in a branch one for instance.
</Tooltip>
{/snippet}
<Toggle
size="xs"
bind:checked={flowModule.continue_on_error}
options={{
left: 'Stop on error and propagate error up',
right: "Continue on error with error as step's return"
}}
/>
</Section>
<div class="mt-4"></div>
<Section label="Retries">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/flows/retries"
>
If defined, upon error this step will be retried with a delay and a
maximum number of attempts as defined below.
</Tooltip>
{/snippet}
<FlowRetries
bind:flowModuleRetry={flowModule.retry}
bind:flowModule
{isAgentTool}
/>
</Section>
{:else if advancedSelected === 'runtime' && advancedRuntimeSelected === 'concurrency'}
<Section label="Concurrency limits" class="flex flex-col gap-4" eeOnly>
{#snippet header()}
<Tooltip>Allowed concurrency within a given timeframe</Tooltip>
{/snippet}
{#if flowModule.value.type == 'rawscript'}
<Label label="Max number of executions within the time window">
<div class="flex flex-row gap-2 max-w-sm whitespace-nowrap">
<input
disabled={!$enterpriseLicense}
bind:value={flowModule.value.concurrent_limit}
type="number"
/>
<Button
size="xs"
variant="default"
on:click={() => {
if (flowModule.value.type == 'rawscript') {
flowModule.value.concurrent_limit = undefined
}
}}
>
<div class="flex flex-row gap-2"> Remove Limits </div>
</Button>
</div>
</Label>
<Label label="Time window in seconds">
<SecondsInput
disabled={!$enterpriseLicense}
bind:seconds={flowModule.value.concurrency_time_window_s}
clearable
/>
</Label>
<Label label="Custom concurrency key (optional)">
{#snippet header()}
<Tooltip>
Concurrency keys are global, you can have them be workspace
specific using the variable `$workspace`. You can also use an
argument's value using `$args[name_of_arg]`</Tooltip
>
{/snippet}
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
autofocus
disabled={!$enterpriseLicense}
bind:value={flowModule.value.custom_concurrency_key}
placeholder={`$workspace/script/${$pathStore}-$args[foo]`}
/>
</Label>
{:else if flowModule.value.type == 'script'}
<WorkspaceScriptSettingInfo
label="Concurrency limit"
active={referencedConcurrentLimit != undefined}
valueText={referencedConcurrentLimit != undefined
? `Max ${referencedConcurrentLimit} execution${
referencedConcurrentLimit === 1 ? '' : 's'
}${
referencedScriptSettings.settings?.concurrency_time_window_s !=
undefined
? ` within ${referencedScriptSettings.settings.concurrency_time_window_s}s`
: ''
}`
: undefined}
loading={referencedScriptSettings.loading}
error={referencedScriptSettings.error}
canEdit={canEditWorkspaceScriptSettings}
noEditReason={workspaceScriptNoEditReason}
onEdit={openWorkspaceScriptSettings}
/>
{:else}
<Alert type="warning" title="Limitation" size="xs">
The concurrency limit of a referenced flow is only settable in the
flow settings directly.
</Alert>
{/if}
</Section>
{:else if advancedSelected === 'runtime' && advancedRuntimeSelected === 'timeout'}
<div>
<FlowModuleTimeout
previousModuleId={previousModule?.id}
bind:flowModule
/>
</div>
{:else if advancedSelected === 'runtime' && advancedRuntimeSelected === 'priority'}
<Section label="Priority" class="flex flex-col gap-4">
<!-- TODO: Add EE-only badge when we have it -->
<Toggle
disabled={!$enterpriseLicense || isCloudHosted()}
checked={flowModule.priority !== undefined && flowModule.priority > 0}
on:change={() => {
if (flowModule.priority) {
flowModule.priority = undefined
} else {
flowModule.priority = 100
}
}}
options={{
right: 'Enabled high priority flow step',
rightTooltip: `Jobs scheduled from this step when the flow is executed are labeled as high priority and take precedence over the other jobs in the jobs queue. ${
!$enterpriseLicense
? 'This is a feature only available on enterprise edition.'
: ''
}`
}}
/>
<Label label="Priority number">
{#snippet header()}
<Tooltip>The higher the number, the higher the priority.</Tooltip>
{/snippet}
<input
type="number"
class="!w-24"
disabled={flowModule.priority === undefined}
bind:value={flowModule.priority}
onfocus={bubble('focus')}
onchange={() => {
if (flowModule.priority && flowModule.priority > 100) {
flowModule.priority = 100
} else if (flowModule.priority && flowModule.priority < 0) {
flowModule.priority = 0
}
}}
/>
</Label>
<Alert type="warning" title="Limitation" size="xs">
Setting priority is only available for enterprise edition and not
available on the cloud.
</Alert>
</Section>
{:else if advancedSelected === 'runtime' && advancedRuntimeSelected === 'lifetime'}
<div>
<FlowModuleDeleteAfterUse
bind:flowModule
disabled={!$enterpriseLicense}
/>
</div>
{:else if advancedSelected === 'cache'}
<div>
<FlowModuleCache
bind:flowModule
workspaceScriptCacheTtl={referencedCacheTtl}
loadingWorkspaceScript={referencedScriptSettings.loading}
workspaceScriptError={referencedScriptSettings.error}
canEditWorkspaceScript={canEditWorkspaceScriptSettings}
{workspaceScriptNoEditReason}
onEditWorkspaceScript={openWorkspaceScriptSettings}
/>
</div>
{:else if advancedSelected === 'early-stop'}
<FlowModuleEarlyStop bind:flowModule />
{:else if advancedSelected === 'skip'}
<FlowModuleSkip bind:flowModule {parentModule} {previousModule} />
{:else if advancedSelected === 'suspend'}
<div>
<FlowModuleSuspend
previousModuleId={previousModule?.id}
bind:flowModule
/>
</div>
{:else if advancedSelected === 'sleep'}
<div>
<FlowModuleSleep
previousModuleId={previousModule?.id}
bind:flowModule
{isAgentTool}
/>
</div>
{:else if advancedSelected === 'debounce'}
<div>
<FlowModuleDebounce bind:flowModule {selectedId} />
</div>
{:else if advancedSelected === 'mock'}
<div>
<FlowModuleMockTransitionMessage />
</div>
{:else if advancedSelected === 'same_worker'}
<div>
<Alert type="info" title="Share a directory between steps">
If shared directory is set, will share a folder that will be mounted on
`./shared` for each of them to pass data between each other.
</Alert>
<Button
btnClasses="mt-4"
on:click={() => {
selectionManager.selectId('settings-same-worker')
}}
>
Set shared directory in the flow settings
</Button>
</div>
{:else if advancedSelected === 's3'}
<div>
<h2 class="pb-4">
S3 snippets
<Tooltip>
Read/Write object from/to S3 and leverage Polars and DuckDB to run
efficient ETL processes.
</Tooltip>
</h2>
</div>
<div class="flex gap-2 justify-between mb-4 items-center">
<div class="flex gap-2">
<ToggleButtonGroup bind:selected={s3Kind} class="w-auto">
{#snippet children({ item })}
{#if flowModule.value['language'] === 'deno'}
<ToggleButton
value="s3_client"
small
label="S3 lite client"
{item}
/>
{:else}
<ToggleButton value="s3_client" small label="Boto3" {item} />
<ToggleButton value="polars" small label="Polars" {item} />
<ToggleButton value="duckdb" small label="DuckDB" {item} />
{/if}
{/snippet}
</ToggleButtonGroup>
</div>
<Button
size="xs"
on:click={() =>
editor?.setCode(s3Scripts[flowModule.value['language']][s3Kind])}
>
Apply snippet
</Button>
</div>
<HighlightCode
language={flowModule.value['language']}
code={s3Scripts[flowModule.value['language']][s3Kind]}
/>
{/if}
</div>
<FlowRunSettings
bind:this={runSettings}
onApplyS3Snippet={(code) => editor?.setCode(code)}
bind:flowModule
{isAgentTool}
{parentModule}
{previousModule}
{selectedId}
{referencedConcurrentLimit}
referencedConcurrencyTimeWindowS={referencedScriptSettings.settings
?.concurrency_time_window_s}
workspaceScriptCacheTtl={referencedCacheTtl}
loadingWorkspaceScript={referencedScriptSettings.loading}
workspaceScriptError={referencedScriptSettings.error}
canEditWorkspaceScript={canEditWorkspaceScriptSettings}
{workspaceScriptNoEditReason}
onEditWorkspaceScript={openWorkspaceScriptSettings}
/>
{/if}
</div>
</Pane>
@@ -1710,7 +1391,10 @@
{scriptProgress}
mod={flowModule}
linkedAgentTools={agentLinked
? getLinkedAgentTools(linkedToolsScope(opWs, $pathStore), linkedToolsModuleId)
? getLinkedAgentTools(
linkedToolsScope(opWs, $pathStore),
linkedToolsModuleId
)
: undefined}
{testIsLoading}
disableMock={preprocessorModule || failureModule}
@@ -56,4 +56,6 @@
schema={flowStateStore.val[selectedId]?.schema}
placeholder={`$workspace/flow/<flow_path>-${flowModule.id}`}
size="xs"
fontClass="text-xs font-normal text-primary"
indentContent
/>
@@ -1,10 +1,9 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import type { FlowModule } from '$lib/gen'
import { stepSettingDefaults } from '../flowStepSettings'
import { SecondsInput } from '$lib/components/common'
import Section from '$lib/components/Section.svelte'
import { slideDynamic } from '$lib/transitions'
interface Props {
flowModule: FlowModule
@@ -14,45 +13,33 @@
let { flowModule = $bindable(), disabled = false }: Props = $props()
let enabled = $derived(flowModule.delete_after_secs != null)
const tip =
'The logs, arguments and results of this flow step are permanently deleted after the configured delay once the flow completes (they may be briefly visible in the UI while running). This also applies to a failed step: the error will not be accessible. The deletion is irreversible. Set to 0 for immediate deletion.'
</script>
<Section label="Delete after completion">
{#snippet header()}
<Tooltip>
The logs, arguments and results of this flow step will be completely deleted from Windmill
after the specified delay once the flow is complete. They might be temporarily visible in UI
while the flow is running.
<br />
This also applies to a flow step that has failed: the error will not be accessible.
<br />
<br />
The deletion is irreversible. Set to 0 for immediate deletion.
{#if disabled}
<br />
<br />
This option is only available on Windmill Enterprise Edition.
{/if}
</Tooltip>
{/snippet}
<div class="flex flex-col gap-2">
<Toggle
{disabled}
size="sm"
eeOnly
size="xs"
textClass="text-xs font-normal text-primary"
checked={enabled}
on:change={() => {
if (enabled) {
flowModule.delete_after_secs = undefined
} else {
flowModule.delete_after_secs = 0
flowModule.delete_after_secs = stepSettingDefaults('lifetime')
}
}}
options={{
right: 'Delete logs, arguments and results after the flow is complete'
right: 'Delete after use',
rightTooltip: tip
}}
/>
{#if enabled}
<div class="mt-2">
<div class="pl-9" transition:slideDynamic>
<SecondsInput bind:seconds={flowModule.delete_after_secs} {disabled} size="sm" />
</div>
{/if}
</Section>
</div>
@@ -1,26 +1,43 @@
<script lang="ts">
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import PropPickerWrapper from '$lib/components/flows/propPicker/PropPickerWrapper.svelte'
import type { Flow, FlowModule } from '$lib/gen'
import Tooltip from '$lib/components/Tooltip.svelte'
import { stepSettingDefaults } from '../flowStepSettings'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
import type { Flow, FlowModule, StopAfterIf } from '$lib/gen'
import Label from '$lib/components/Label.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import type { ExtendedOpenFlow, FlowEditorContext } from '../types'
import { getContext } from 'svelte'
import { emptySchema } from '$lib/utils'
import { NEVER_TESTED_THIS_FAR } from '../models'
import Section from '$lib/components/Section.svelte'
import { getStepPropPicker } from '../previousResults'
import { dfs } from '../previousResults'
import { slideDynamic } from '$lib/transitions'
const { flowStateStore, flowStore, previewArgs } =
getContext<FlowEditorContext>('FlowEditorContext')
interface Props {
flowModule: FlowModule
/** A loop shows both predicates, and puts the per-iteration one (`stop_after_if`)
* next to its own settings rather than in the run-settings list. */
blocks?: 'both' | 'stop-after' | 'all-iters'
}
let { flowModule = $bindable() }: Props = $props()
let { flowModule = $bindable(), blocks = 'both' }: Props = $props()
let stopAfterEditor: SimpleEditor | undefined = $state(undefined)
let stopAfterAllItersEditor: SimpleEditor | undefined = $state(undefined)
// Both predicates are stored as a bare `{ expr }`, so the form is told their kind
// through `argType` rather than inferring one from the value.
let predicateSchema = $state(emptySchema())
predicateSchema.properties['stop_after_if'] = { type: 'boolean' }
predicateSchema.properties['stop_after_all_iters_if'] = { type: 'boolean' }
let editor: SimpleEditor | undefined = $state(undefined)
let stepPropPicker = $derived(
getStepPropPicker(
flowStateStore.val,
@@ -50,12 +67,20 @@
}
return null
}
let raise_error_message_stop_after_all_if = $state(
flowModule.stop_after_all_iters_if?.error_message != undefined
)
let raise_error_message_stop_after_if = $state(
flowModule.stop_after_if?.error_message != undefined
)
// `skip_if_stopped` and `error_message` are mutually exclusive in the worker, and
// setting neither is a third outcome — so the three are one choice, not two flags.
type StopStatus = 'success' | 'skipped' | 'error'
function stopStatus(stop: StopAfterIf): StopStatus {
if (stop.skip_if_stopped) return 'skipped'
if (stop.error_message != undefined) return 'error'
return 'success'
}
function setStopStatus(stop: StopAfterIf, status: StopStatus) {
stop.skip_if_stopped = status === 'skipped'
stop.error_message = status === 'error' ? (stop.error_message ?? '') : undefined
if (status !== 'error') stop.error_include_result = false
}
let { isLoop, isParallelLoop } = $derived(
flowModule.value.type === 'forloopflow' || flowModule.value.type === 'whileloopflow'
? { isLoop: true, isParallelLoop: flowModule.value.parallel ?? false }
@@ -66,327 +91,248 @@
let isStopAfterAllIterationsEnabled = $derived(Boolean(flowModule.stop_after_all_iters_if))
let result = $derived(flowStateStore.val[flowModule.id]?.previewResult ?? NEVER_TESTED_THIS_FAR)
let breakableParent = $derived(checkIfBreakableParent(flowStore.val))
</script>
<div class="flex flex-col items-start space-y-2">
{#if !isBranchAll && !isParallelLoop}
<Section
label={(isLoop
? 'Break loop'
// One `stop_after_if` field, but what stopping early *does* depends on where the step
// sits — so the name and the explanation are picked together rather than sharing one
// tooltip that has to enumerate every case.
let stopAfterCopy = $derived(
isParallelLoop
? {
label: 'Break loop if',
tooltip:
'Unavailable on a parallel loop: iterations don\'t run in sequence, so there is nothing to break out of and the worker skips this predicate. Use "Stop flow if" to decide once every iteration has completed.'
}
: isLoop
? {
label: 'Break loop if',
tooltip:
'Evaluated after each iteration. When it returns true the loop stops iterating and the flow carries on with the iterations collected so far.'
}
: breakableParent
? breakableParent.isParallel
? breakableParent.type === 'loop'
? 'Skip rest of steps in iteration'
: 'Skip rest of steps in branch'
: 'Break parent loop module ' + breakableParent.stepId
: 'Stop flow early') + (isLoop ? ' (evaluated after each iteration)' : '')}
class="w-full"
>
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/early_stop">
If defined, at the end of the step, the predicate expression will be evaluated to decide
if the flow should stop early, skip rest of steps in iteration/branch if inside a parallel
for loop or branch all, or break if inside a for/while loop or branch all.
</Tooltip>
{/snippet}
? {
label: 'Skip rest of iteration if',
tooltip:
'Evaluated after this step. When it returns true the remaining steps of this iteration are skipped; the other iterations are unaffected.'
}
: {
label: 'Skip rest of branch if',
tooltip:
'Evaluated after this step. When it returns true the remaining steps of this branch are skipped; the other branches are unaffected.'
}
: {
label: 'Break parent loop if',
tooltip: `Evaluated after this step. When it returns true the enclosing loop ${breakableParent.stepId} stops iterating and the flow carries on.`
}
: {
label: 'Stop flow if',
tooltip:
"Evaluated after this step. When it returns true the flow stops here and returns this step's result."
}
)
<Toggle
checked={isStopAfterIfEnabled}
on:change={() => {
if (isStopAfterIfEnabled && flowModule.stop_after_if) {
flowModule.stop_after_if = undefined
} else {
flowModule.stop_after_if = {
expr: 'result == undefined',
skip_if_stopped: false,
error_message: undefined,
error_include_result: false
// The all-iterations predicate runs once the loop or branch-all is done, over what
// every iteration returned, so it can decide on the whole rather than on one result.
let allItersPrefix = $derived(
`Evaluated once ${isBranchAll ? 'every branch' : 'every iteration'} has completed, over their collected results.`
)
let stopAfterAllItersCopy = $derived(
breakableParent
? breakableParent.isParallel
? breakableParent.type === 'loop'
? {
label: 'Skip rest of iteration if',
tooltip: `${allItersPrefix} When it returns true the remaining steps of the enclosing iteration are skipped.`
}
: {
label: 'Skip rest of branch if',
tooltip: `${allItersPrefix} When it returns true the remaining steps of the enclosing branch are skipped.`
}
: {
label: `Break parent loop ${breakableParent.stepId} if`,
tooltip: `${allItersPrefix} When it returns true the enclosing loop stops iterating and the flow carries on.`
}
: {
label: 'Stop flow if',
tooltip: `${allItersPrefix} When it returns true the flow stops here and returns them.`
}
)
let earlyStopResult = $derived(
isLoop
? Array.isArray(result) && result.length > 0
? result[result.length - 1]
: result === NEVER_TESTED_THIS_FAR
? result
: undefined
: result
)
</script>
{#snippet stopStatusPicker(stop: StopAfterIf)}
<div class="flex flex-col gap-2 pl-9" transition:slideDynamic>
<Label
label="Flow status"
tooltip="How the flow is reported once this condition stops it. Success returns this step's result, Skipped marks the flow as skipped, and Error fails it."
>
<ToggleButtonGroup
noWFull
selected={stopStatus(stop)}
onSelected={(v) => setStopStatus(stop, v)}
>
{#snippet children({ item })}
<ToggleButton value="success" label="Success" {item} small />
<ToggleButton value="skipped" label="Skipped" {item} small />
<ToggleButton value="error" label="Error" {item} small />
{/snippet}
</ToggleButtonGroup>
</Label>
{#if stop.error_message != undefined}
<div class="flex flex-col gap-2" transition:slideDynamic>
<TextInput
size="sm"
bind:value={() => stop.error_message ?? '', (v) => (stop.error_message = String(v))}
inputProps={{ placeholder: 'Enter custom error message (optional)' }}
/>
<Toggle
size="xs"
bind:checked={
() => stop.error_include_result ?? false, (v) => (stop.error_include_result = v)
}
options={{
right: 'Include result in error',
rightTooltip:
"When enabled, this step's output is embedded inside the raised error object (as error.result) instead of being discarded. The flow result stays { error }."
}}
/>
</div>
{/if}
</div>
{/snippet}
{#snippet stopAfterToggle()}
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
disabled={isParallelLoop}
checked={isStopAfterIfEnabled}
on:change={() => {
if (isStopAfterIfEnabled && flowModule.stop_after_if) {
flowModule.stop_after_if = undefined
} else {
flowModule.stop_after_if = stepSettingDefaults('early-stop')
}
}}
options={{
title: isParallelLoop ? stopAfterCopy.tooltip : undefined,
right: stopAfterCopy.label,
rightTooltip: stopAfterCopy.tooltip,
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/early_stop'
}}
/>
{/snippet}
{#snippet stopAfterAllItersToggle()}
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
checked={isStopAfterAllIterationsEnabled}
on:change={() => {
if (isStopAfterAllIterationsEnabled && flowModule.stop_after_all_iters_if) {
flowModule.stop_after_all_iters_if = undefined
} else {
flowModule.stop_after_all_iters_if = stepSettingDefaults('early-stop')
}
}}
options={{
right: stopAfterAllItersCopy.label,
rightTooltip: stopAfterAllItersCopy.tooltip,
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/early_stop'
}}
/>
{/snippet}
<div class="flex flex-col items-start gap-6">
{#if blocks !== 'all-iters' && !isBranchAll}
<div class="w-full flex flex-col gap-2">
<PropPickerWrapper
popover={true}
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
result={earlyStopResult}
extraResults={isLoop ? { all_iters: result } : undefined}
displayContext={false}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
stopAfterEditor?.insertAtCursor(detail)
stopAfterEditor?.focus()
}}
>
<InputTransformForm
bind:arg={
() => flowModule.stop_after_if,
(v) => {
flowModule.stop_after_if = v
}
}
}}
options={{
right: isLoop
? 'Break loop'
: breakableParent
? breakableParent.isParallel
? breakableParent.type === 'loop'
? 'Skip rest of steps in iteration'
: 'Skip rest of steps in branch'
: 'Break parent loop module'
: 'Stop flow if condition met'
}}
/>
<div
class="w-full mt-2 border rounded-md p-2 flex flex-col gap-2 {flowModule.stop_after_if
? ''
: 'bg-surface-secondary'}"
>
{#if flowModule.stop_after_if}
{@const earlyStopResult = isLoop
? Array.isArray(result) && result.length > 0
? result[result.length - 1]
: result === NEVER_TESTED_THIS_FAR
? result
: undefined
: result}
{#if !breakableParent && !isLoop}
<div class="flex flex-col gap-2">
<Toggle
size="xs"
bind:checked={flowModule.stop_after_if.skip_if_stopped}
on:change={(event) => {
if (flowModule.stop_after_if && event.detail) {
flowModule.stop_after_if.error_message = undefined
flowModule.stop_after_if.error_include_result = false
raise_error_message_stop_after_if = false
}
}}
options={{
right: 'Label flow as "skipped" if stopped'
}}
/>
<Toggle
size="xs"
bind:checked={raise_error_message_stop_after_if}
on:change={(event) => {
if (flowModule.stop_after_if) {
if (event.detail) {
flowModule.stop_after_if.error_message = ''
flowModule.stop_after_if.skip_if_stopped = false
} else {
flowModule.stop_after_if.error_message = undefined
flowModule.stop_after_if.error_include_result = false
}
}
}}
options={{
right: 'Raise an error message if stopped',
rightTooltip:
'If enabled and the stop condition is met, an error message will be raised. A custom message can be provided; otherwise, a default message will be used. Mutually exclusive with "Label flow as skipped".'
}}
/>
</div>
{/if}
{#if raise_error_message_stop_after_if}
<input
type="text"
bind:value={flowModule.stop_after_if.error_message}
placeholder="Enter custom error message (optional)"
/>
<Toggle
size="xs"
bind:checked={flowModule.stop_after_if.error_include_result}
options={{
right: "Include the stopping step's result in the error",
rightTooltip:
"When enabled, this step's output is embedded inside the raised error object (as error.result) instead of being discarded. The flow result stays { error }."
}}
/>
{/if}
<span class="mt-2 text-xs font-bold">Stop condition expression</span>
<div class="border rounded-md w-full overflow-auto">
<PropPickerWrapper
noPadding
notSelectable
pickableProperties={stepPropPicker.pickableProperties}
result={earlyStopResult}
extraResults={isLoop ? { all_iters: result } : undefined}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<SimpleEditor
bind:this={editor}
lang="javascript"
bind:code={flowModule.stop_after_if.expr}
class="h-full"
extraLib={`declare const result = ${JSON.stringify(earlyStopResult)};\n` +
stepPropPicker.extraLib +
(isLoop ? `\ndeclare const all_iters = ${JSON.stringify(result)};` : '')}
/>
</PropPickerWrapper>
</div>
{:else}
{#if !breakableParent && !isLoop}
<div class="flex flex-col gap-2">
<Toggle
disabled
size="xs"
options={{
right: 'Label flow as "skipped" if stopped'
}}
/>
<Toggle
disabled
size="xs"
options={{
right: 'Raise an error message if stopped'
}}
/>
</div>
{/if}
<span class="mt-2 text-xs font-bold">Stop condition expression</span>
<textarea disabled rows="3" class="min-h-[80px]"></textarea>
{/if}
</div>
</Section>
argName="stop_after_if"
argType="javascript"
collapsed={!isStopAfterIfEnabled || isParallelLoop}
animateAppear
header={stopAfterToggle}
noDynamicToggle
schema={predicateSchema}
previousModuleId={undefined}
pickableProperties={stepPropPicker.pickableProperties}
extraLib={`declare const result = ${JSON.stringify(earlyStopResult)};\n` +
stepPropPicker.extraLib +
(isLoop ? `\ndeclare const all_iters = ${JSON.stringify(result)};` : '')}
bind:editor={stopAfterEditor}
/>
</PropPickerWrapper>
{#if isStopAfterIfEnabled && !breakableParent && !isLoop && flowModule.stop_after_if}
{@render stopStatusPicker(flowModule.stop_after_if)}
{/if}
</div>
{/if}
{#if isLoop || isBranchAll}
<Section
label={(breakableParent
? breakableParent.isParallel
? breakableParent.type === 'loop'
? 'Skip rest of steps in iteration'
: 'Skip rest of steps in branch'
: 'Break parent loop module ' + breakableParent.stepId
: 'Stop flow early') +
(isBranchAll
? ' (evaluated after all branches have been run)'
: ' (evaluated after all iterations)')}
class="w-full"
>
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/early_stop">
If defined, at the end of the step, the predicate expression will be evaluated to decide
if the flow should stop early, skip rest of steps in iteration/branch if inside a parallel
for loop or branch all, or break if inside a for/while loop or branch all.
</Tooltip>
{/snippet}
<Toggle
checked={isStopAfterAllIterationsEnabled}
on:change={() => {
if (isStopAfterAllIterationsEnabled && flowModule.stop_after_all_iters_if) {
flowModule.stop_after_all_iters_if = undefined
} else {
flowModule.stop_after_all_iters_if = {
expr: 'result == undefined',
skip_if_stopped: false,
error_message: undefined,
error_include_result: false
{#if blocks !== 'stop-after' && (isLoop || isBranchAll)}
<div class="w-full flex flex-col gap-2">
<PropPickerWrapper
popover={true}
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
{result}
displayContext={false}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
stopAfterAllItersEditor?.insertAtCursor(detail)
stopAfterAllItersEditor?.focus()
}}
>
<InputTransformForm
bind:arg={
() => flowModule.stop_after_all_iters_if,
(v) => {
flowModule.stop_after_all_iters_if = v
}
}
}}
options={{
right:
(breakableParent
? breakableParent.isParallel
? breakableParent.type === 'loop'
? 'Skip rest of steps in iteration'
: 'Skip rest of steps in branch'
: 'Break parent loop module ' + breakableParent.stepId
: 'Stop flow') + ' if condition met'
}}
/>
<div
class="w-full border rounded-md mt-2 p-2 flex flex-col gap-2 {flowModule.stop_after_all_iters_if
? ''
: 'bg-surface-secondary'}"
>
{#if flowModule.stop_after_all_iters_if}
{#if !breakableParent}
<div class="flex flex-col gap-2">
<Toggle
size="xs"
bind:checked={flowModule.stop_after_all_iters_if.skip_if_stopped}
on:change={(event) => {
if (flowModule.stop_after_all_iters_if && event.detail) {
flowModule.stop_after_all_iters_if.error_message = undefined
flowModule.stop_after_all_iters_if.error_include_result = false
raise_error_message_stop_after_all_if = false
}
}}
options={{
right: 'Label flow as "skipped" if stopped'
}}
/>
<Toggle
size="xs"
bind:checked={raise_error_message_stop_after_all_if}
on:change={(event) => {
if (flowModule.stop_after_all_iters_if) {
if (event.detail) {
flowModule.stop_after_all_iters_if.error_message = ''
flowModule.stop_after_all_iters_if.skip_if_stopped = false
} else {
flowModule.stop_after_all_iters_if.error_message = undefined
flowModule.stop_after_all_iters_if.error_include_result = false
}
}
}}
options={{
right: 'Raise an error message if stopped',
rightTooltip:
'If enabled and the stop condition is met, an error message will be raised. A custom message can be provided; otherwise, a default message will be used. Mutually exclusive with "Label flow as skipped".'
}}
/>
</div>
{/if}
{#if raise_error_message_stop_after_all_if}
<input
type="text"
bind:value={flowModule.stop_after_all_iters_if.error_message}
placeholder="Enter custom error message (optional)"
/>
<Toggle
size="xs"
bind:checked={flowModule.stop_after_all_iters_if.error_include_result}
options={{
right: "Include the stopping step's result in the error",
rightTooltip:
"When enabled, this step's output is embedded inside the raised error object (as error.result) instead of being discarded. The flow result stays { error }."
}}
/>
{/if}
<span class="mt-2 text-xs font-bold">Stop condition expression</span>
<div class="border rounded-md w-full overflow-auto">
<PropPickerWrapper
notSelectable
noPadding
pickableProperties={stepPropPicker.pickableProperties}
{result}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<SimpleEditor
bind:this={editor}
lang="javascript"
bind:code={flowModule.stop_after_all_iters_if.expr}
class="h-full"
extraLib={`declare const result = ${JSON.stringify(result)};\n` +
stepPropPicker.extraLib}
/>
</PropPickerWrapper>
</div>
{:else}
{#if !breakableParent}
<div class="flex flex-col gap-2">
<Toggle
disabled
size="xs"
options={{
right: 'Label flow as "skipped" if stopped'
}}
/>
<Toggle
disabled
size="xs"
options={{
right: 'Raise an error message if stopped'
}}
/>
</div>
{/if}
<span class="mt-2 text-xs font-bold">Stop condition expression</span>
<textarea disabled rows="3" class="min-h-[80px]"></textarea>
{/if}
</div>
</Section>
argName="stop_after_all_iters_if"
argType="javascript"
collapsed={!isStopAfterAllIterationsEnabled}
animateAppear
header={stopAfterAllItersToggle}
noDynamicToggle
schema={predicateSchema}
previousModuleId={undefined}
pickableProperties={stepPropPicker.pickableProperties}
extraLib={`declare const result = ${JSON.stringify(result)};\n` + stepPropPicker.extraLib}
bind:editor={stopAfterAllItersEditor}
/>
</PropPickerWrapper>
{#if isStopAfterAllIterationsEnabled && !breakableParent && flowModule.stop_after_all_iters_if}
{@render stopStatusPicker(flowModule.stop_after_all_iters_if)}
{/if}
</div>
{/if}
</div>
@@ -2,24 +2,10 @@
import Button from '$lib/components/common/button/Button.svelte'
import { type FlowModule } from '$lib/gen'
import { createEventDispatcher, getContext } from 'svelte'
import {
Bed,
Database,
Gauge,
GitFork,
Pen,
PhoneIncoming,
RefreshCcw,
Repeat,
Square,
Pin,
Save,
Settings
} from 'lucide-svelte'
import Popover from '../../Popover.svelte'
import { Pen, RefreshCcw, Save } from 'lucide-svelte'
import DropdownV2 from '../../DropdownV2.svelte'
import type { FlowEditorContext } from '../types'
import { sendUserToast } from '$lib/utils'
import { getLatestHashForScript } from '$lib/scripts'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import FlowModuleWorkerTagSelect from './FlowModuleWorkerTagSelect.svelte'
@@ -29,134 +15,14 @@
}
let { module, tag }: Props = $props()
const { scriptEditorDrawer, workspaceScriptSettingsDrawer, flowEditorDrawer, opWorkspace } =
getContext<FlowEditorContext>('FlowEditorContext')
const { flowEditorDrawer } = getContext<FlowEditorContext>('FlowEditorContext')
const dispatch = createEventDispatcher()
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
let popoverClasses =
'center-center rounded p-2 bg-blue-100 text-blue-800 border border-blue-300 hover:bg-blue-200 dark:bg-frost-700 dark:text-frost-100 dark:border-frost-600'
</script>
<div class="flex flex-row gap-2 whitespace-nowrap">
{#if module.value.type === 'script' || module.value.type === 'rawscript' || module.value.type == 'flow'}
{#if module.retry?.constant || module.retry?.exponential}
<Popover placement="bottom" class={popoverClasses} onClick={() => dispatch('toggleRetry')}>
<Repeat size={14} />
{#snippet text()}
Retries
{/snippet}
</Popover>
{/if}
{#if module?.value?.['concurrent_limit'] != undefined}
<Popover
placement="bottom"
class={popoverClasses}
onClick={() => dispatch('toggleConcurrency')}
>
<Gauge size={14} />
{#snippet text()}
Concurrency Limits
{/snippet}
</Popover>
{/if}
{#if module.cache_ttl != undefined}
<Popover placement="bottom" class={popoverClasses} onClick={() => dispatch('toggleCache')}>
<Database size={14} />
{#snippet text()}
Cache
{/snippet}
</Popover>
{/if}
{#if module.stop_after_if || module.stop_after_all_iters_if}
<Popover
placement="bottom"
class={popoverClasses}
onClick={() => dispatch('toggleStopAfterIf')}
>
<Square size={14} />
{#snippet text()}
Early stop/break
{/snippet}
</Popover>
{/if}
{#if module.suspend}
<Popover placement="bottom" class={popoverClasses} onClick={() => dispatch('toggleSuspend')}>
<PhoneIncoming size={14} />
{#snippet text()}
Suspend
{/snippet}
</Popover>
{/if}
{#if module.sleep}
<Popover placement="bottom" class={popoverClasses} onClick={() => dispatch('toggleSleep')}>
<Bed size={14} />
{#snippet text()}
Sleep
{/snippet}
</Popover>
{/if}
{#if module.mock?.enabled}
<Popover placement="bottom" class={popoverClasses} onClick={() => dispatch('togglePin')}>
<Pin size={14} />
{#snippet text()}
This step is pinned
{/snippet}
</Popover>
{/if}
{/if}
<div class="flex shrink-0 flex-row gap-2 whitespace-nowrap">
{#if module.value.type === 'script'}
{#if !module.value.path.startsWith('hub/') && customUi?.scriptEdit != false}
<Popover notClickable placement="bottom">
<Button
unifiedSize="sm"
variant="subtle"
onClick={async () => {
if (module.value.type == 'script') {
const hash =
module.value.hash ??
(await getLatestHashForScript(module.value.path, opWorkspace?.()))
$scriptEditorDrawer?.openDrawer(hash, () => {
dispatch('reload')
sendUserToast('Script has been updated')
})
}
}}
startIcon={{ icon: Pen }}
iconOnly
aria-label="Edit the script's code"
disabled={module.value.hash != undefined}
/>
{#snippet text()}Edit the script's code{/snippet}
</Popover>
<!-- Only when the settings drawer is actually mounted (not in the local-dev
editors, which provide the context store but never render it). -->
{#if $workspaceScriptSettingsDrawer}
<Popover notClickable placement="bottom">
<Button
unifiedSize="sm"
variant="subtle"
onClick={() => {
if (module.value.type == 'script') {
$workspaceScriptSettingsDrawer?.openDrawer(
module.value.path,
module.value.hash,
() => {
dispatch('reload')
}
)
}
}}
startIcon={{ icon: Settings }}
iconOnly
aria-label="Edit the script's runtime settings"
disabled={module.value.hash != undefined}
/>
{#snippet text()}Edit the script's runtime settings (concurrency, cache, timeout, ...){/snippet}
</Popover>
{/if}
{/if}
{#if customUi?.tagEdit != false}
<FlowModuleWorkerTagSelect
isPreprocessor={module.id == 'preprocessor'}
@@ -167,19 +33,6 @@
on:change={(e) => dispatch('tagChange', e.detail)}
/>
{/if}
{#if customUi?.scriptFork != false}
<Popover notClickable placement="bottom">
<Button
unifiedSize="sm"
variant="subtle"
on:click={() => dispatch('fork')}
startIcon={{ icon: GitFork }}
iconOnly
aria-label="Fork into an inline script"
/>
{#snippet text()}Fork into an inline script{/snippet}
</Popover>
{/if}
{:else if module.value.type === 'flow'}
<Button
unifiedSize="sm"
@@ -230,14 +83,16 @@
tag={module.value.tag}
on:change={(e) => dispatch('tagChange', e.detail)}
/>
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: Save }}
on:click={() => dispatch('createScriptFromInlineScript')}
iconOnly={false}
>
Save to workspace
</Button>
<DropdownV2
size="sm"
placement="bottom-end"
items={[
{
displayName: 'Save to workspace',
icon: Save,
action: () => dispatch('createScriptFromInlineScript')
}
]}
/>
{/if}
</div>
@@ -2,11 +2,11 @@
import { run } from 'svelte/legacy'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import type { FlowModule } from '$lib/gen'
import { Section } from '$lib/components/common'
import Label from '$lib/components/Label.svelte'
import JsonEditor from '$lib/components/JsonEditor.svelte'
import { untrack } from 'svelte'
import { slideDynamic } from '$lib/transitions'
interface Props {
flowModule: FlowModule
@@ -56,47 +56,38 @@
}
</script>
<Section label="Mock">
{#snippet header()}
<div class="flex flex-row items-center gap-2">
<Tooltip>
If defined and enabled, the step will immediately return the mock value instead of being
executed.
</Tooltip>
<Toggle
checked={isMockEnabled}
on:change={() => {
if (isMockEnabled) {
flowModule.mock = {
enabled: false,
return_value: flowModule.mock?.return_value
}
} else {
flowModule.mock = {
enabled: true,
return_value: flowModule.mock?.return_value ?? { example: 'value' }
}
code = JSON.stringify(flowModule.mock?.return_value, null, 2)
}
}}
size="xs"
/>
<div class="flex flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
checked={isMockEnabled}
on:change={() => {
if (isMockEnabled) {
flowModule.mock = {
enabled: false,
return_value: flowModule.mock?.return_value
}
} else {
flowModule.mock = {
enabled: true,
return_value: flowModule.mock?.return_value ?? { example: 'value' }
}
code = JSON.stringify(flowModule.mock?.return_value, null, 2)
}
}}
options={{
right: 'Pin output',
rightTooltip:
'While pinned, the step returns this value immediately instead of executing. The same control lives on the step in the graph.'
}}
/>
{#if isMockEnabled}
<div class="pl-9" transition:slideDynamic>
<Label label="Pinned value">
{#key renderCount}
<JsonEditor {code} on:changeValue={updateMockValue} />
{/key}
</Label>
</div>
{/snippet}
<div>
<span class="text-xs py-1">Mocked Return value</span>
{#if isMockEnabled}
{#key renderCount}
<JsonEditor {code} on:changeValue={updateMockValue} />
{/key}
{:else}
<pre class="text-xs border rounded p-2 bg-surface-disabled"
>{flowModule.mock?.return_value
? JSON.stringify(flowModule.mock?.return_value, null, 2)
: ''}</pre
>
{/if}
</div>
</Section>
{/if}
</div>
@@ -1,145 +0,0 @@
<script lang="ts">
import { Pin } from 'lucide-svelte'
import { base } from '$lib/base'
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import { Info } from 'lucide-svelte'
let darkMode = $state(true)
function openFullscreen(event: MouseEvent) {
const img = event.target as HTMLImageElement
if (img.requestFullscreen) {
img.requestFullscreen({
navigationUI: 'auto'
})
}
}
</script>
<DarkModeObserver bind:darkMode />
<div class="p-1.5">
<!-- Header Banner -->
<div
class="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20
text-blue-700 dark:text-blue-300 rounded-md px-3 py-2 flex items-center justify-between mb-3"
>
<div class="flex items-center gap-2">
<div
class="bg-blue-600 dark:bg-blue-400 px-1.5 py-0.5 text-white text-xs font-bold tracking-wide rounded shadow-sm"
>NEW</div
>
<span class="font-normal text-sm">Mock has evolved into</span>
<span class="font-semibold flex items-center gap-0.5 text-sm">
<Pin size="14" strokeWidth="2.5" />
PIN
</span>
</div>
<div class="text-xs bg-white/60 dark:bg-slate-800/60 py-0.5 px-2 rounded">
<span class="font-medium">Find it in:</span>
<span class="font-semibold">"Test this step"</span> tab
</div>
</div>
<!-- Main Content -->
<div class="text-xs">
<!-- Steps Header -->
<span class="flex items-center mb-2 text-slate-500"> How to use the PIN feature: </span>
<!-- Steps Container -->
<div class="grid grid-cols-2 gap-3">
<!-- Step 1 -->
<div class="flex items-start gap-2 group">
<div
class="bg-surface-secondary text-secondary rounded-full w-5 h-5 flex items-center justify-center text-xs font-medium flex-shrink-0 -mt-0.5"
>1</div
>
<div class="w-full">
<div class="font-medium mb-1.5">Pick a result from history</div>
<div
class="border rounded-md p-1 bg-white dark:bg-slate-800 h-56 overflow-hidden shadow-sm group-hover:shadow transition-shadow"
>
<!--svelte-ignore a11y_click_events_have_key_events-->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<img
class="w-full h-full object-contain opacity-70 hover:opacity-100 cursor-pointer"
src={darkMode ? `${base}/pin-history-dark.png` : `${base}/pin-history.png`}
alt="History picker"
onclick={openFullscreen}
/>
</div>
</div>
</div>
<!-- Step 2 -->
<div class="flex items-start gap-2 group">
<div
class="bg-surface-secondary text-secondary rounded-full w-5 h-5 flex items-center justify-center text-xs font-medium flex-shrink-0 -mt-0.5"
>2</div
>
<div class="w-full">
<div class="font-medium mb-1.5">Pin it as a fixed output</div>
<div
class="border rounded-md p-1 bg-white dark:bg-slate-800 h-56 overflow-hidden shadow-sm group-hover:shadow transition-shadow"
>
<!--svelte-ignore a11y_click_events_have_key_events-->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<img
class="w-full h-full object-contain opacity-70 hover:opacity-100 cursor-pointer"
src={darkMode ? `${base}/pin-pin-dark.png` : `${base}/pin-pin.png`}
alt="Pin action"
onclick={openFullscreen}
/>
</div>
</div>
</div>
<!-- Step 3 -->
<div class="flex items-start gap-2 group">
<div
class="bg-surface-secondary text-secondary rounded-full w-5 h-5 flex items-center justify-center text-xs font-medium flex-shrink-0 -mt-0.5"
><Info size="14" /></div
>
<div class="w-full">
<div class="font-medium mb-1.5">The last pin can be recovered from history</div>
<div
class="border rounded-md p-1 bg-white dark:bg-slate-800 h-56 overflow-hidden shadow-sm group-hover:shadow transition-shadow"
>
<!--svelte-ignore a11y_click_events_have_key_events-->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<img
class="w-full h-full object-contain opacity-70 hover:opacity-100 cursor-pointer"
src={darkMode ? `${base}/pin-restore-dark.png` : `${base}/pin-restore.png`}
alt="Recover pins"
onclick={openFullscreen}
/>
</div>
</div>
</div>
<!-- Step 4 -->
<div class="flex items-start gap-2 group">
<div
class="bg-surface-secondary text-secondary rounded-full w-5 h-5 flex items-center justify-center text-xs font-medium flex-shrink-0 -mt-0.5"
>
<Info size="14" />
</div>
<div class="w-full">
<div class="font-medium mb-1.5">All of this can be done from the flow view</div>
<div
class="border rounded-md p-1 bg-white dark:bg-slate-800 h-56 overflow-hidden shadow-sm group-hover:shadow transition-shadow"
>
<!--svelte-ignore a11y_click_events_have_key_events-->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<img
class="w-full h-full object-contain opacity-70 hover:opacity-100 cursor-pointer"
src={darkMode ? `${base}/pin-flow-view-dark.png` : `${base}/pin-flow-view.png`}
alt="Flow view pinning"
onclick={openFullscreen}
/>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1,12 +1,13 @@
<script lang="ts">
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import PropPickerWrapper from '$lib/components/flows/propPicker/PropPickerWrapper.svelte'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
import type { FlowModule } from '$lib/gen'
import Tooltip from '$lib/components/Tooltip.svelte'
import { stepSettingDefaults } from '../flowStepSettings'
import type { FlowEditorContext } from '../types'
import { getContext } from 'svelte'
import Section from '$lib/components/Section.svelte'
import { emptySchema } from '$lib/utils'
import { getStepPropPicker } from '../previousResults'
const { flowStateStore, flowStore, previewArgs } =
@@ -21,6 +22,12 @@
let { flowModule = $bindable(), parentModule, previousModule }: Props = $props()
let editor: SimpleEditor | undefined = $state(undefined)
// A predicate is stored as a bare `{ expr }`, so the form is told its kind through
// `argType` rather than inferring one from the value.
let schema = $state(emptySchema())
schema.properties['skip_if'] = { type: 'boolean' }
let stepPropPicker = $derived(
getStepPropPicker(
flowStateStore.val,
@@ -33,64 +40,66 @@
)
)
// The worker evaluates skip_if before this step runs, passing the last job result,
// so `result` here is the previous step's output — not this step's.
let result = $derived(
previousModule ? flowStateStore.val[previousModule.id]?.previewResult : undefined
)
let isSkipEnabled = $derived(Boolean(flowModule.skip_if))
</script>
<div class="flex flex-col items-start space-y-2">
<Section label="Skip" class="w-full">
{#snippet header()}
<Tooltip>
If the condition is met, the step will behave as an identity step, passing the previous
step's result through unchanged.
</Tooltip>
{/snippet}
{#snippet skipToggle()}
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
checked={isSkipEnabled}
on:change={() => {
if (isSkipEnabled && flowModule.skip_if) {
flowModule.skip_if = undefined
} else {
flowModule.skip_if = stepSettingDefaults('skip')
}
}}
options={{
right: 'Skip step if',
rightTooltip:
"If the condition is met, the step behaves as an identity step, passing the previous step's result through unchanged."
}}
/>
{/snippet}
<Toggle
checked={isSkipEnabled}
on:change={() => {
if (isSkipEnabled && flowModule.skip_if) {
flowModule.skip_if = undefined
} else {
flowModule.skip_if = {
expr: 'false'
}
<div class="w-full">
<PropPickerWrapper
popover={true}
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
{result}
displayContext={false}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<InputTransformForm
bind:arg={
() => flowModule.skip_if,
(v) => {
flowModule.skip_if = v
}
}}
options={{
right: 'Skip step if condition is met'
}}
}
argName="skip_if"
argType="javascript"
collapsed={!isSkipEnabled}
animateAppear
header={skipToggle}
noDynamicToggle
{schema}
previousModuleId={previousModule?.id}
pickableProperties={stepPropPicker.pickableProperties}
extraLib={`declare const result = ${JSON.stringify(result)};\n` + stepPropPicker.extraLib}
bind:editor
/>
<div
class="w-full border rounded-md p-2 mt-2 flex flex-col gap-1 {flowModule.skip_if
? ''
: 'bg-surface-secondary'}"
>
{#if flowModule.skip_if}
<span class="mt-2 text-xs font-bold">Skip condition expression</span>
<div class="border rounded-md w-full overflow-auto">
<PropPickerWrapper
noPadding
notSelectable
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<SimpleEditor
bind:this={editor}
lang="javascript"
bind:code={flowModule.skip_if.expr}
class="h-full"
extraLib={stepPropPicker.extraLib}
/>
</PropPickerWrapper>
</div>
{:else}
<span class="mt-2 text-xs font-bold">Skip condition expression</span>
<textarea disabled rows="3" class="min-h-[80px]"></textarea>
{/if}
</div>
</Section>
</PropPickerWrapper>
</div>
@@ -2,16 +2,14 @@
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import type { FlowModule } from '$lib/gen'
import { stepSettingDefaults } from '../flowStepSettings'
import { emptySchema } from '$lib/utils'
import { getContext } from 'svelte'
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
import type { FlowEditorContext } from '../types'
import { SecondsInput } from '../../common'
import Section from '$lib/components/Section.svelte'
import Label from '$lib/components/Label.svelte'
import { getStepPropPicker } from '../previousResults'
import { slideDynamic } from '$lib/transitions'
import { SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte'
import { Alert } from '$lib/components/common'
@@ -51,66 +49,54 @@
let sameWorker = $derived(Boolean(!isAgentTool && flowStore.val.value.same_worker))
</script>
<Section label="Sleep" class="w-full">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/sleep">
If defined, at the end of the step, the flow will sleep for a number of seconds before
scheduling the next job (if any, no effect if the step is the last one).
</Tooltip>
{/snippet}
<div class="flex flex-col gap-2">
{#if sameWorker}
<Alert type="warning" size="xs" title="Disabled by the shared directory" class="mb-4">
<Alert type="warning" size="xs" title="Disabled by the shared directory">
{SAME_WORKER_INCOMPATIBLE_MSG} Disable `Same Worker` in the flow settings to use a sleep.
</Alert>
{/if}
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
checked={isSleepEnabled}
disabled={sameWorker}
class="mb-6"
on:change={() => {
if (isSleepEnabled && flowModule.sleep != undefined) {
flowModule.sleep = undefined
} else {
flowModule.sleep = {
type: 'static',
value: 0
}
flowModule.sleep = stepSettingDefaults('sleep')
}
}}
options={{
right: 'Sleep after module successful execution'
right: 'Sleep after step',
rightTooltip:
'At the end of the step, the flow sleeps for a number of seconds before scheduling the next job (no effect if the step is the last one).',
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/sleep'
}}
/>
<Label label="Sleep for duration">
{#if flowModule.sleep && schema.properties['sleep'] && !sameWorker}
<div class="border rounded-md overflow-auto">
<PropPickerWrapper
noFlowPlugConnect={true}
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
{result}
displayContext={false}
pickableProperties={undefined}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<InputTransformForm
bind:arg={flowModule.sleep}
argName="sleep"
{schema}
{previousModuleId}
argExtra={{ seconds: true, clearable: false }}
bind:editor
/>
</PropPickerWrapper>
</div>
{:else}
<SecondsInput disabled />
<div class="text-secondary text-xs">OR use a dynamic expression</div>
{/if}
</Label>
</Section>
{#if flowModule.sleep && schema.properties['sleep'] && !sameWorker}
<div class="pl-9" transition:slideDynamic>
<PropPickerWrapper
popover={true}
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
{result}
displayContext={false}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<InputTransformForm
bind:arg={flowModule.sleep}
argName="sleep"
{schema}
{previousModuleId}
argExtra={{ seconds: true, clearable: false }}
bind:editor
/>
</PropPickerWrapper>
</div>
{/if}
</div>
@@ -1,22 +1,23 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { stepSettingDefaults } from '../flowStepSettings'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { getContext, tick, untrack } from 'svelte'
import { Alert, Tab, Tabs } from '$lib/components/common'
import { Alert, Button, Tab, Tabs } from '$lib/components/common'
import { GroupService, type FlowModule } from '$lib/gen'
import { emptySchema, emptyString } from '$lib/utils'
import { enterpriseLicense, workspaceStore } from '$lib/stores.js'
import { SecondsInput } from '../../common'
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
import type { FlowEditorContext } from '../types'
import Section from '$lib/components/Section.svelte'
import Label from '$lib/components/Label.svelte'
import SuspendDrawer from './SuspendDrawer.svelte'
import EditableSchemaDrawer from '$lib/components/schema/EditableSchemaDrawer.svelte'
import AddProperty from '$lib/components/schema/AddProperty.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { Pen, Plus } from 'lucide-svelte'
import { slideDynamic } from '$lib/transitions'
const { selectionManager, flowStateStore, opWorkspace } =
getContext<FlowEditorContext>('FlowEditorContext')
@@ -69,152 +70,157 @@
}
})
let jsonView: boolean = $state(false)
let formEditor: EditableSchemaDrawer | undefined = $state(undefined)
// Stands in for the form's schema until the step has one, so the editor can be mounted
// (and thus openable) before the first field exists.
let draftFormSchema = $state(emptySchema())
function openFormEditor() {
if (flowModule.suspend && !flowModule.suspend.resume_form) {
flowModule.suspend.resume_form = { schema: draftFormSchema }
}
formEditor?.openDrawer()
}
</script>
<Section label="Suspend/Approval/Prompt" class="w-full">
{#snippet action()}
<SuspendDrawer text="Approval/Prompt helpers" />
{/snippet}
{#snippet header()}
<div class="flex flex-row items-center gap-2">
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_approval">
If defined, at the end of the step, the flow will be suspended until it receives external
requests to be resumed or canceled. This is most useful to implement approval steps but can
be used flexibly for other purposes.
</Tooltip>
<Toggle
size="xs"
checked={isSuspendEnabled}
on:change={() => {
if (isSuspendEnabled && flowModule.suspend != undefined) {
flowModule.suspend = undefined
} else {
flowModule.suspend = {
required_events: 1,
timeout: 1800
}
}
}}
options={{
right: 'Suspend flow execution until events/approvals received'
}}
/>
</div>
{/snippet}
<div class="flex w-full flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
checked={isSuspendEnabled}
on:change={() => {
if (isSuspendEnabled && flowModule.suspend != undefined) {
flowModule.suspend = undefined
} else {
flowModule.suspend = stepSettingDefaults('suspend')
}
}}
options={{
right: 'Suspend until approval/resume',
rightTooltip:
'At the end of the step, the flow is suspended until it receives external requests to resume or cancel it. Most useful for approval steps, but can be used flexibly for other purposes.',
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/flow_approval'
}}
/>
<div class="overflow-x-auto scrollbar-hidden">
<Tabs bind:selected={suspendTabSelected}>
<Tab value="core" disabled={!isSuspendEnabled} label="Core" />
<Tab value="form" disabled={!isSuspendEnabled} label="Form" />
<Tab value="permissions" disabled={!isSuspendEnabled} label="Permissions" />
</Tabs>
</div>
{#if isSuspendEnabled}
<div class="flex flex-col gap-3 pl-9" transition:slideDynamic>
<div class="overflow-x-auto scrollbar-hidden">
<Tabs bind:selected={suspendTabSelected}>
<Tab value="core" label="Core" />
<Tab value="form" label="Form" />
<Tab value="permissions" label="Permissions" />
</Tabs>
</div>
{#if suspendTabSelected === 'core'}
<div class="flex flex-col mt-4 gap-4">
<Label label="Number of approvals/events required for resuming flow">
{#if flowModule.suspend}
<input
bind:value={flowModule.suspend.required_events}
type="number"
min="1"
placeholder="1"
/>
{:else}
<input type="number" disabled />
{/if}
</Label>
<Label label="Timeout">
{#if flowModule.suspend}
<SecondsInput bind:seconds={flowModule.suspend.timeout} />
{:else}
<SecondsInput disabled />
{/if}
</Label>
{#if suspendTabSelected === 'core'}
<div class="flex flex-col gap-3">
<Label label="Number of approvals/events required for resuming flow">
{#if flowModule.suspend}
<input
bind:value={flowModule.suspend.required_events}
type="number"
min="1"
placeholder="1"
/>
{:else}
<input type="number" disabled />
{/if}
</Label>
<Label label="Timeout">
{#if flowModule.suspend}
<SecondsInput bind:seconds={flowModule.suspend.timeout} />
{:else}
<SecondsInput disabled />
{/if}
</Label>
<Toggle
options={{
right: 'Continue on disapproval/timeout',
rightTooltip: `Instead of failing the flow and bubbling up the error, continue to the next step which would allow to put a branchone right after to handle both cases separately.
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
options={{
right: 'Continue on disapproval/timeout',
rightTooltip: `Instead of failing the flow and bubbling up the error, continue to the next step which would allow to put a branchone right after to handle both cases separately.
If any disapproval/timeout event is received, the resume payload will be similar to every error result in Windmill, an object containing an "error" field which you can use
to distinguish between approvals and disapproval/timeouts.
We recommend using the expr "resume?.error" to handle null payload values.
To filter timeout, use "resume?.error?.name === "SuspendedTimedOut"
To filter disapproval, use "resume?.error?.name === "SuspendedDisapproved"`
}}
checked={Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
disabled={!Boolean(flowModule.suspend)}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.continue_on_disapprove_timeout = e.detail
}
}}
/>
{#if Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
<Alert type="info" title="Continue on disapproval/timeout">
We recommend using the expr <code>resume?.error</code> to handle null payload values.
<br />
To filter timeout, use <code>resume?.error?.name === "SuspendedTimedOut"</code>. <br />
To filter disapproval, use <code>resume?.error?.name === "SuspendedDisapproved"</code>
</Alert>
{/if}
</div>
{:else if suspendTabSelected === 'permissions'}
<div class="flex flex-col mt-4 gap-4">
{#if emptyString($enterpriseLicense)}
<Alert type="warning" title="Editing permissions is only available in enterprise version" />
{/if}
{#if flowModule.suspend}
<div class="flex flex-col gap-2">
<Toggle
disabled={emptyString($enterpriseLicense)}
checked={Boolean(flowModule.suspend.user_auth_required)}
options={{
right: 'Require approvers to be logged in'
}}
checked={Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
disabled={!Boolean(flowModule.suspend)}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.user_auth_required = e.detail
if (e.detail && flowModule.suspend?.user_groups_required === undefined) {
flowModule.suspend.user_groups_required = {
type: 'static',
value: []
flowModule.suspend.continue_on_disapprove_timeout = e.detail
}
}}
/>
{#if Boolean(flowModule.suspend?.continue_on_disapprove_timeout)}
<Alert type="info" title="Continue on disapproval/timeout">
We recommend using the expr <code>resume?.error</code> to handle null payload values.
<br />
To filter timeout, use <code>resume?.error?.name === "SuspendedTimedOut"</code>.
<br />
To filter disapproval, use <code>resume?.error?.name === "SuspendedDisapproved"</code>
</Alert>
{/if}
</div>
{:else if suspendTabSelected === 'permissions'}
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
eeOnly
disabled={!flowModule.suspend || emptyString($enterpriseLicense)}
checked={Boolean(flowModule.suspend?.user_auth_required)}
options={{
right: 'Require approvers to be logged in'
}}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.user_auth_required = e.detail
if (e.detail && flowModule.suspend?.user_groups_required === undefined) {
flowModule.suspend.user_groups_required = {
type: 'static',
value: []
}
} else if (!e.detail) {
flowModule.suspend.user_groups_required = undefined
flowModule.suspend.self_approval_disabled = false
}
} else if (!e.detail) {
flowModule.suspend.user_groups_required = undefined
flowModule.suspend.self_approval_disabled = false
}
}
}}
/>
}}
/>
<Toggle
options={{
right: 'Disable self-approval',
rightTooltip: 'The user who triggered the flow will not be allowed to approve it'
}}
checked={Boolean(flowModule.suspend.self_approval_disabled)}
disabled={!Boolean(flowModule.suspend.user_auth_required)}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.self_approval_disabled = e.detail
}
}}
/>
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
eeOnly
options={{
right: 'Disable self-approval',
rightTooltip: 'The user who triggered the flow will not be allowed to approve it'
}}
checked={Boolean(flowModule.suspend?.self_approval_disabled)}
disabled={!flowModule.suspend || !Boolean(flowModule.suspend?.user_auth_required)}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.self_approval_disabled = e.detail
}
}}
/>
<div class="mb-4"></div>
<div class="mb-4"></div>
{#if Boolean(flowModule.suspend.user_auth_required) && allUserGroups.length !== 0 && flowModule.suspend && schema.properties['groups']}
<span class="text-xs font-bold"
>Require approvers to be members of one of the following user groups (leave empty for
any)
</span>
<div class="border">
{#if Boolean(flowModule.suspend?.user_auth_required) && allUserGroups.length !== 0 && flowModule.suspend && schema.properties['groups']}
<span class="text-xs font-bold"
>Require approvers to be members of one of the following user groups (leave empty
for any)
</span>
<PropPickerWrapper
popover={true}
{result}
noFlowPlugConnect
displayContext={false}
pickableProperties={undefined}
on:select={({ detail }) => {
@@ -231,75 +237,79 @@
bind:editor
/>
</PropPickerWrapper>
{/if}
</div>
</div>
{:else}
<div class="flex flex-col gap-4">
{#if flowModule?.suspend?.resume_form}
<div class="flex justify-end">
<Button
unifiedSize="xs"
variant="default"
startIcon={{ icon: Pen }}
on:click={openFormEditor}
>
Edit form
</Button>
</div>
<!-- The approval page renders the form with the same component; here it only
shows what approvers will see, and editing happens in the drawer. -->
<div class="rounded-md border p-2">
<SchemaForm
schema={flowModule.suspend.resume_form.schema}
disabled
noVariablePicker
/>
</div>
{:else if emptyString($enterpriseLicense)}
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
{:else}
<Button
unifiedSize="md"
variant="default"
btnClasses="w-full border-dashed"
startIcon={{ icon: Plus }}
on:click={openFormEditor}
>
Add a form
</Button>
{/if}
<EditableSchemaDrawer
bind:this={formEditor}
bind:schema={
() => flowModule.suspend?.resume_form?.schema ?? draftFormSchema,
(v) => {
if (flowModule.suspend) {
flowModule.suspend.resume_form = { schema: v }
}
}
}
drawerOnly
/>
{#if flowModule.suspend?.resume_form}
<Toggle
textClass="text-xs font-normal text-primary"
size="xs"
checked={Boolean(flowModule.suspend.hide_cancel)}
on:change={(e) => {
if (flowModule.suspend) {
flowModule.suspend.hide_cancel = e.detail
}
}}
options={{
right: 'Hide cancel button on approval page'
}}
/>
{/if}
</div>
{/if}
</div>
{:else}
<div class="grid grid-cols-4 mt-4 gap-8">
<div class="col-span-2">
{#if flowModule?.suspend?.resume_form}
<EditableSchemaDrawer bind:schema={flowModule.suspend.resume_form.schema} {jsonView} />
{:else if emptyString($enterpriseLicense)}
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
{:else}
<div class="flex flex-col items-end mb-2 w-full">
<Toggle
checked={false}
size="xs"
options={{
right: 'JSON editor',
rightTooltip:
'Arguments can be edited either using the wizard, or by editing their JSON Schema.'
}}
lightMode
on:change={() => {
if (flowModule.suspend) {
flowModule.suspend.resume_form = {
schema: emptySchema()
}
}
jsonView = true
}}
/>
</div>
<AddProperty
on:change={(e) => {
jsonView = false
if (flowModule.suspend) {
flowModule.suspend.resume_form = {
schema: e.detail
}
}
}}
schema={{}}
/>
{/if}
</div>
<div class="col-span-2 flex flex-col gap-4">
{#if flowModule.suspend}
{#if emptyString($enterpriseLicense)}
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
{/if}
<div class="flex flex-col gap-2">
<div class="flex">
<SuspendDrawer text="Default args & Dynamic enums help" />
</div>
</div>
{/if}
{#if flowModule.suspend}
<Toggle
bind:checked={flowModule.suspend.hide_cancel}
size="xs"
options={{
right: 'Hide cancel button on approval page'
}}
disabled={!Boolean(flowModule?.suspend?.resume_form)}
/>
{/if}
<div class="flex justify-end">
<SuspendDrawer text="Approval/Prompt helpers" />
</div>
</div>
{/if}
</Section>
</div>
@@ -1,19 +1,17 @@
<script lang="ts">
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import type SimpleEditor from '$lib/components/SimpleEditor.svelte'
import Section from '$lib/components/Section.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import Label from '$lib/components/Label.svelte'
import type { FlowModule } from '$lib/gen'
import { Alert, SecondsInput } from '../../common'
import { Alert } from '../../common'
import { emptySchema } from '$lib/utils'
import { getContext } from 'svelte'
import PropPickerWrapper from '$lib/components/flows/propPicker/PropPickerWrapper.svelte'
import type { FlowEditorContext } from '../types'
import { getStepPropPicker } from '../previousResults'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import { slideDynamic } from '$lib/transitions'
interface Props {
flowModule: FlowModule
@@ -56,15 +54,10 @@
let istimeoutEnabled = $derived(Boolean(flowModule.timeout))
</script>
<Section label="Timeout">
{#snippet header()}
<Tooltip>
If defined, the custom timeout will be used instead of the instance timeout for the step. The
step's timeout cannot be greater than the instance timeout.
</Tooltip>
{/snippet}
<div class="flex flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
checked={istimeoutEnabled}
on:change={() => {
if (istimeoutEnabled && flowModule.timeout != undefined) {
@@ -77,39 +70,37 @@
}
}}
options={{
right: 'Add a custom timeout for this step'
right: 'Custom timeout',
rightTooltip:
"The custom timeout is used instead of the instance timeout for the step. The step's timeout cannot be greater than the instance timeout."
}}
/>
<Label label="Timeout duration" class="mt-2">
{#if flowModule.timeout && schema.properties['timeout']}
<div class="border">
<PropPickerWrapper
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<InputTransformForm
bind:arg={flowModule.timeout}
argName="timeout"
{schema}
{previousModuleId}
argExtra={{ seconds: true }}
bind:editor
/>
</PropPickerWrapper>
</div>
{:else}
<SecondsInput disabled />
<div class="text-secondary text-sm">OR use a dynamic expression</div>
{/if}
</Label>
{#if flowModule.timeout && schema.properties['timeout']}
<div class="pl-9" transition:slideDynamic>
<PropPickerWrapper
popover={true}
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<InputTransformForm
bind:arg={flowModule.timeout}
argName="timeout"
{schema}
{previousModuleId}
argExtra={{ seconds: true }}
bind:editor
/>
</PropPickerWrapper>
</div>
{/if}
{#if flowModule.timeout && flowModule.timeout.type !== 'static'}
<div class="mt-4">
<div class="mt-4 pl-9" transition:slideDynamic>
<Alert title="Dynamic timeout only used when testing the full flow" type="info">
<p class="text-xs">
A dynamic timeout expression is evaluated when running the full flow. It is ignored when
@@ -118,4 +109,4 @@
</Alert>
</div>
{/if}
</Section>
</div>
@@ -1,8 +1,11 @@
<script lang="ts">
import { moduleSlot, savedModuleById } from '../moduleSlot'
import FlowModuleWrapper from './FlowModuleWrapper.svelte'
import { type FlowModule } from '$lib/gen'
import { getContext } from 'svelte'
import { stepSettingDefaults } from '$lib/components/flows/flowStepSettings'
import { emptyString } from '$lib/utils'
import type { FlowEditorContext } from '../types'
import FlowLoop from './FlowLoop.svelte'
import FlowModuleComponent from './FlowModuleComponent.svelte'
@@ -19,6 +22,7 @@
import FlowBranchesAllWrapper from './FlowBranchesAllWrapper.svelte'
import FlowBranchesOneWrapper from './FlowBranchesOneWrapper.svelte'
import FlowWhileLoop from './FlowWhileLoop.svelte'
import FlowCard from '../common/FlowCard.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import { formatCron } from '$lib/utils'
import AgentToolWrapper from './AgentToolWrapper.svelte'
@@ -88,10 +92,7 @@
}
}
module.stop_after_if = {
expr: 'result == undefined || Array.isArray(result) && result.length == 0',
skip_if_stopped: true
}
module.stop_after_if = stepSettingDefaults('early-stop', 'trigger')
}
async function createModuleFromScript(
path: string,
@@ -109,7 +110,7 @@
)
if (kind == 'approval') {
module.suspend = { required_events: 1, timeout: 1800 }
module.suspend = stepSettingDefaults('suspend')
}
if (kind == 'trigger') {
@@ -127,7 +128,7 @@
{:else if flowModule.value.type === 'whileloopflow'}
<FlowWhileLoop {noEditor} bind:mod={flowModule} {previousModule} {parentModule} />
{:else if flowModule.value.type === 'branchone'}
<FlowBranchesOneWrapper {noEditor} {previousModule} {parentModule} bind:flowModule {enableAi} />
<FlowBranchesOneWrapper {noEditor} {previousModule} {parentModule} {enableAi} bind:flowModule />
{:else if flowModule.value.type === 'branchall'}
<FlowBranchesAllWrapper {noEditor} {previousModule} {parentModule} bind:flowModule />
{:else if flowModule.value.type === 'identity'}
@@ -169,7 +170,14 @@
selectedId == 'preprocessor'}
on:pick={async ({ detail }) => {
const { path, summary, kind, hash } = detail
createModuleFromScript(path, summary, kind, hash)
// The picked script's summary is a default: anything already typed on the step
// was the user's choice and outranks it.
createModuleFromScript(
path,
emptyString(flowModule.summary) ? summary : flowModule.summary,
kind,
hash
)
}}
on:new={async ({ detail }) => {
const { language, kind, subkind, summary } = detail
@@ -189,7 +197,7 @@
}
if (kind == 'approval') {
module.suspend = { required_events: 1, timeout: 1800 }
module.suspend = stepSettingDefaults('suspend')
}
flowModule = module
@@ -217,16 +225,18 @@
/>
{/if}
{:else if flowModule.value.type === 'forloopflow' || flowModule.value.type == 'whileloopflow'}
{#each flowModule.value.modules as _, index (index)}
{#each flowModule.value.modules as child, index (child.id ?? index)}
{@const slot = moduleSlot(
() => (flowModule.value as { modules: FlowModule[] }).modules,
child.id,
child
)}
<FlowModuleWrapper
{noEditor}
bind:flowModule={flowModule.value.modules[index]}
bind:flowModule={slot.get, slot.set}
bind:parentModule={flowModule}
previousModule={flowModule.value.modules[index - 1]}
savedModule={savedModule?.value.type === 'forloopflow' ||
savedModule?.value.type === 'whileloopflow'
? savedModule.value.modules[index]
: undefined}
savedModule={savedModuleById(savedModule, child.id)}
{enableAi}
{forceTestTab}
{highlightArg}
@@ -234,45 +244,52 @@
{/each}
{:else if flowModule.value.type === 'branchone'}
{#if selectedId === `${flowModule?.id}-branch-default`}
<div class="p-2">
<h3 class="mb-4">Default branch</h3>
Nothing to configure, this is the default branch if none of the predicates are met.
<div class="h-full flex flex-col">
<FlowCard {noEditor} title="Default branch">
<div class="p-4">
<p class="text-xs text-tertiary">
Nothing to configure — this branch runs when none of the predicates match.
</p>
</div>
</FlowCard>
</div>
{:else}
{#each flowModule.value.default as _, index}
{#each flowModule.value.default as child, index (child.id ?? index)}
{@const slot = moduleSlot(
() => (flowModule.value as { default: FlowModule[] }).default,
child.id,
child
)}
<FlowModuleWrapper
{noEditor}
bind:flowModule={flowModule.value.default[index]}
bind:flowModule={slot.get, slot.set}
bind:parentModule={flowModule}
previousModule={flowModule.value.default[index - 1]}
savedModule={savedModule?.value.type === 'branchone'
? savedModule.value.default[index]
: undefined}
savedModule={savedModuleById(savedModule, child.id)}
{enableAi}
{forceTestTab}
{highlightArg}
/>
{/each}
{/if}
{#each flowModule.value.branches as branch, branchIndex (branchIndex)}
{#each flowModule.value.branches as branch, branchIndex (branch)}
{#if selectedId === `${flowModule?.id}-branch-${branchIndex}`}
<FlowBranchOneWrapper
{noEditor}
bind:branch={flowModule.value.branches[branchIndex]}
{branch}
parentModule={flowModule}
{previousModule}
{enableAi}
/>
{:else}
{#each branch.modules as _, index}
{#each branch.modules as child, index (child.id ?? index)}
{@const slot = moduleSlot(() => branch.modules, child.id, child)}
<FlowModuleWrapper
{noEditor}
bind:flowModule={flowModule.value.branches[branchIndex].modules[index]}
bind:flowModule={slot.get, slot.set}
bind:parentModule={flowModule}
previousModule={flowModule.value.branches[branchIndex].modules[index - 1]}
savedModule={savedModule?.value.type === 'branchone'
? savedModule.value.branches[branchIndex]?.modules[index]
: undefined}
savedModule={savedModuleById(savedModule, child.id)}
{enableAi}
{forceTestTab}
{highlightArg}
@@ -281,20 +298,19 @@
{/if}
{/each}
{:else if flowModule.value.type === 'branchall'}
{#each flowModule.value.branches as branch, branchIndex (branchIndex)}
{#each flowModule.value.branches as branch, branchIndex (branch)}
{#if selectedId === `${flowModule?.id}-branch-${branchIndex}`}
<FlowBranchAllWrapper {noEditor} bind:branch={flowModule.value.branches[branchIndex]} />
<FlowBranchAllWrapper {noEditor} {branch} />
{:else}
{#each branch.modules as _, index}
{#each branch.modules as child, index (child.id ?? index)}
{@const slot = moduleSlot(() => branch.modules, child.id, child)}
<FlowModuleWrapper
{noEditor}
bind:flowModule={flowModule.value.branches[branchIndex].modules[index]}
bind:flowModule={slot.get, slot.set}
bind:parentModule={flowModule}
previousModule={flowModule.value.branches[branchIndex].modules[index - 1]}
{enableAi}
savedModule={savedModule?.value.type === 'branchall'
? savedModule.value.branches[branchIndex]?.modules[index]
: undefined}
savedModule={savedModuleById(savedModule, child.id)}
{forceTestTab}
{highlightArg}
/>
@@ -1,9 +1,6 @@
<script lang="ts">
import FlowExecutionStatus from '$lib/components/runs/FlowExecutionStatus.svelte'
import type { Job } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import FlowCard from '../common/FlowCard.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import type { StateStore } from '$lib/utils'
@@ -20,9 +17,6 @@
}
let { job, isOwner, suspendStatus, noEditor, onOpenDetails }: Props = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
</script>
<FlowCard {noEditor} title="Flow result">
@@ -44,7 +38,6 @@
{#if isOwner !== undefined && suspendStatus}
<FlowExecutionStatus
{job}
workspaceId={opWs}
{isOwner}
innerModules={job?.flow_status?.modules}
{suspendStatus}
@@ -7,14 +7,15 @@
import { untrack, getContext } from 'svelte'
import Toggle from '$lib/components/Toggle.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import PropPickerWrapper from '$lib/components/flows/propPicker/PropPickerWrapper.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import Section from '$lib/components/Section.svelte'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import PropPickerWrapper from '../propPicker/PropPickerWrapper.svelte'
import { emptySchema } from '$lib/utils'
import type { FlowEditorContext } from '../types'
import { getStepPropPicker } from '../previousResults'
import { NEVER_TESTED_THIS_FAR } from '../models'
import { validateRetryConfig } from '$lib/utils'
import EEOnly from '$lib/components/EEOnly.svelte'
import { slideDynamic } from '$lib/transitions'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte'
@@ -40,9 +41,14 @@
}
let delayType = $state() as 'disabled' | 'constant' | 'exponential' | undefined
let retryIfEditor: SimpleEditor | undefined = $state(undefined)
// `retry_if` is stored as a bare `{ expr }`, so the form is told its kind through
// `argType` rather than inferring one from the value.
let predicateSchema = $state(emptySchema())
predicateSchema.properties['retry_if'] = { type: 'boolean' }
let loaded = $state(false)
let editor: SimpleEditor | undefined = $state(undefined)
let stepPropPicker = $derived(
flowModule && flowStateStore?.val && flowStore?.val && previewArgs?.val
? getStepPropPicker(
@@ -66,6 +72,58 @@
let isDisabled = $derived(disabled || sameWorker)
let isRetryConditionEnabled = $derived(Boolean(flowModuleRetry?.retry_if))
// When retries are toggled off the config is still shown (read-only) so the row
// isn't empty: fall back to sensible defaults and force the "constant" tab.
let retriesOff = $derived(!(delayType === 'constant' || delayType === 'exponential'))
let displayDelayType = $derived(delayType === 'exponential' ? 'exponential' : 'constant')
// The shown branch's config must actually exist, or its inputs would bind to the
// display fallback below and silently discard every keystroke.
let shownBranchMissing = $derived(
displayDelayType === 'constant' ? !flowModuleRetry?.constant : !flowModuleRetry?.exponential
)
let cfgDisabled = $derived(disabled || retriesOff || shownBranchMissing)
// The kind selector never takes `shownBranchMissing`: switching kind is how the user
// reseeds a branch, so disabling it there would strand them with no way back.
let kindDisabled = $derived(disabled || retriesOff)
// An external rewrite (AI chat, a session edit) can swap the retry kind under a mounted
// row. Follow it, or the row would disable itself over a config that is perfectly valid.
$effect(() => {
const r = flowModuleRetry
if (!r) return
untrack(() => {
if (delayType === 'constant' && !r.constant && r.exponential) {
delayType = 'exponential'
} else if (delayType === 'exponential' && !r.exponential && r.constant) {
delayType = 'constant'
} else if (delayType === 'disabled' && (r.constant || r.exponential)) {
// Retries added from outside: without this the row keeps reading "off" while
// rendering the incoming values greyed out, and toggling on would overwrite them.
delayType = r.constant ? 'constant' : 'exponential'
}
})
})
let displayRetry = $derived(
flowModuleRetry ?? {
constant: { attempts: 1, seconds: 5 },
exponential: { attempts: 1, multiplier: 1, seconds: 5, random_factor: 0 }
}
)
// Always-defined sub-configs so the read-only off-state can bind without
// undefined checks; when the real config exists these are the same refs.
let displayConstant = $derived(displayRetry.constant ?? { attempts: 1, seconds: 5 })
let displayExponential = $derived(
displayRetry.exponential ?? { attempts: 1, multiplier: 1, seconds: 5, random_factor: 0 }
)
// Only feed the preview the branch that's actually shown, so the off-state
// defaults don't render a bogus combined schedule.
let previewRetry = $derived(
retriesOff
? displayDelayType === 'constant'
? { constant: displayRetry.constant }
: { exponential: displayRetry.exponential }
: flowModuleRetry
)
let result = $derived(
flowModule && flowStateStore?.val
? (flowStateStore.val[flowModule.id]?.previewResult ?? NEVER_TESTED_THIS_FAR)
@@ -99,10 +157,12 @@
}
function initialLoad() {
// Presence, not attempts > 0: a retry block with zero attempts is still
// configured, and flowStepSettings describes it that way.
delayType =
(flowModuleRetry?.constant?.attempts ?? 0) > 0
flowModuleRetry?.constant != undefined
? 'constant'
: (flowModuleRetry?.exponential?.attempts ?? 0) > 0
: flowModuleRetry?.exponential != undefined
? 'exponential'
: 'disabled'
loaded = true
@@ -122,258 +182,289 @@
const u32Max = 4294967295
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
{#if sameWorker}
<Alert type="warning" size="xs" title="Disabled by the shared directory">
{SAME_WORKER_INCOMPATIBLE_MSG} Disable `Same Worker` in the flow settings to use retries.
</Alert>
{/if}
<ToggleButtonGroup
bind:selected={delayType}
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
disabled={isDisabled}
on:selected={(e) => {
flowModuleRetry = undefined
if (e.detail === 'constant') {
checked={delayType === 'constant' || delayType === 'exponential'}
on:change={() => {
if (delayType === 'constant' || delayType === 'exponential') {
flowModuleRetry = undefined
delayType = 'disabled'
} else {
setConstantRetries()
} else if (e.detail === 'exponential') {
flowModuleRetry = undefined
setExponentialRetries()
delayType = 'constant'
}
}}
>
{#snippet children({ item })}
<ToggleButton value="disabled" label="Disabled" {item} />
<ToggleButton value="constant" label="Constant" {item} />
<ToggleButton value="exponential" label="Exponential" {item} />
{/snippet}
</ToggleButtonGroup>
options={{
right: 'Retry on failure',
rightTooltip:
'Upon error this step is retried with a delay and a maximum number of attempts as defined below.',
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/retries'
}}
/>
{#if (delayType === 'constant' || delayType === 'exponential') && !sameWorker}
<Section label="Retry Condition" class="w-full">
{#snippet header()}
<Tooltip>
Optional condition to determine when to retry. If not specified, will retry on any failure
within the configured attempt limits.
</Tooltip>
{/snippet}
<Toggle
checked={isRetryConditionEnabled}
on:change={() => {
if (!flowModuleRetry) {
return
{#if !retriesOff && !sameWorker}
<div class="flex flex-col gap-6 pl-9" transition:slideDynamic>
<ToggleButtonGroup
selected={retriesOff ? displayDelayType : delayType}
disabled={kindDisabled}
class={`${kindDisabled ? 'disabled' : ''}`}
on:selected={(e) => {
flowModuleRetry = undefined
if (e.detail === 'constant') {
setConstantRetries()
delayType = 'constant'
} else if (e.detail === 'exponential') {
setExponentialRetries()
delayType = 'exponential'
}
if (isRetryConditionEnabled && flowModuleRetry.retry_if) {
const { retry_if, ...rest } = flowModuleRetry
flowModuleRetry = rest
} else {
flowModuleRetry = {
...flowModuleRetry,
retry_if: {
expr: 'error && error.name !== "PERMANENT_FAILURE"'
}}
>
{#snippet children({ item })}
<ToggleButton value="constant" label="Constant" {item} />
<ToggleButton value="exponential" label="Exponential" {item} />
{/snippet}
</ToggleButtonGroup>
{#snippet retryConditionToggle()}
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
disabled={cfgDisabled}
checked={isRetryConditionEnabled}
on:change={() => {
if (!flowModuleRetry) {
return
}
if (isRetryConditionEnabled && flowModuleRetry.retry_if) {
const { retry_if, ...rest } = flowModuleRetry
flowModuleRetry = rest
} else {
flowModuleRetry = {
...flowModuleRetry,
retry_if: {
expr: 'error && error.name !== "PERMANENT_FAILURE"'
}
}
}
}
}}
options={{
right: 'Only retry if condition is met'
}}
/>
}}
options={{
right: 'Conditional retry',
rightTooltip:
'Optional condition to determine when to retry. Expression should return true to retry, false to skip retry. If not specified, retries on any failure within the configured attempt limits.'
}}
/>
{/snippet}
<div
class="w-full border rounded-md p-2 mt-2 flex flex-col {flowModuleRetry?.retry_if
? ''
: 'bg-surface-secondary'}"
>
{#if flowModuleRetry?.retry_if}
<span class="mt-2 text-xs font-bold">Retry condition expression</span>
<span class="text-xs text-primary mb-2"
>Expression should return true to retry, false to skip retry</span
>
<div class="border rounded-md overflow-auto w-full">
{#if stepPropPicker}
<PropPickerWrapper
noPadding
notSelectable
pickableProperties={stepPropPicker.pickableProperties}
{result}
on:select={({ detail }) => {
editor?.insertAtCursor(detail)
editor?.focus()
}}
>
<SimpleEditor
bind:this={editor}
lang="javascript"
bind:code={flowModuleRetry.retry_if.expr}
class="h-full"
extraLib={`declare const result = ${JSON.stringify(result)};` +
`\ndeclare const flow_input = ${JSON.stringify(stepPropPicker.pickableProperties.flow_input || {})};`}
/>
</PropPickerWrapper>
{:else}
{#if stepPropPicker}
<PropPickerWrapper
popover={true}
flow_input={stepPropPicker.pickableProperties.flow_input}
notSelectable
{result}
displayContext={false}
pickableProperties={stepPropPicker.pickableProperties}
on:select={({ detail }) => {
retryIfEditor?.insertAtCursor(detail)
retryIfEditor?.focus()
}}
>
<InputTransformForm
bind:arg={
() => flowModuleRetry?.retry_if,
(v) => {
if (flowModuleRetry) flowModuleRetry.retry_if = v
}
}
argName="retry_if"
argType="javascript"
collapsed={!isRetryConditionEnabled}
animateAppear
header={retryConditionToggle}
noDynamicToggle
schema={predicateSchema}
previousModuleId={undefined}
pickableProperties={stepPropPicker.pickableProperties}
extraLib={`declare const result = ${JSON.stringify(result)};` +
`\ndeclare const flow_input = ${JSON.stringify(stepPropPicker.pickableProperties.flow_input || {})};`}
bind:editor={retryIfEditor}
/>
</PropPickerWrapper>
{:else}
<div class="flex flex-col gap-2">
{@render retryConditionToggle()}
{#if flowModuleRetry?.retry_if}
<div class="border rounded-md overflow-auto w-full">
<SimpleEditor
bind:this={editor}
lang="javascript"
bind:code={flowModuleRetry.retry_if.expr}
class="few-lines-editor"
extraLib={`declare const result = ${JSON.stringify(result)};`}
/>
{/if}
</div>
{:else}
<span class="mt-2 text-xs font-bold">Retry condition expression</span>
<span class="text-xs text-primary mb-2"
>Expression should return true to retry, false to skip retry</span
>
<textarea disabled rows="3" class="min-h-[80px]"></textarea>
{/if}
</div>
</Section>
{/if}
</div>
{/if}
</div>
{/if}
{#if (delayType === 'constant' || delayType === 'exponential') && !sameWorker}
<div class="flex h-[calc(100%-22px)]">
<div class="w-1/2 h-full overflow-auto pr-2">
{#if delayType === 'constant'}
{#if flowModuleRetry?.constant}
<div class="text-xs font-bold !mt-2">Attempts</div>
<div class="flex gap-1">
<div class="flex h-[calc(100%-22px)]">
<div class="w-1/2 h-full overflow-auto pr-2">
{#if displayDelayType === 'constant'}
<div class="text-xs font-bold !mt-2">Attempts</div>
<div class="flex gap-1">
<input
max={u32Max.toString()}
disabled={cfgDisabled}
bind:value={displayConstant.attempts}
type="number"
/>
<button
class="text-xs"
disabled={cfgDisabled}
onclick={() =>
flowModuleRetry?.constant && (flowModuleRetry.constant.attempts = u32Max)}
>max</button
>
</div>
<div class="text-xs font-bold !mt-4">Delay</div>
<SecondsInput disabled={cfgDisabled} bind:seconds={displayConstant.seconds} />
{:else if displayDelayType === 'exponential'}
<div class="text-xs font-bold !mt-2">Attempts</div>
<div class="flex gap-1">
<input
max="100"
disabled={cfgDisabled}
bind:value={displayExponential.attempts}
type="number"
/>
<button
class="text-xs"
disabled={cfgDisabled}
onclick={() =>
flowModuleRetry?.exponential && (flowModuleRetry.exponential.attempts = 100)}
>max</button
>
</div>
<div class="text-xs font-bold !mt-2">Multiplier</div>
<span class="text-xs text-primary">delay = multiplier * base ^ (number of attempt)</span
>
<input
max={u32Max.toString()}
bind:value={flowModuleRetry.constant.attempts}
disabled={cfgDisabled}
bind:value={displayExponential.multiplier}
type="number"
/>
<button
class="text-xs"
onclick={() =>
flowModuleRetry?.constant && (flowModuleRetry.constant.attempts = u32Max)}
>max</button
>
</div>
<div class="text-xs font-bold !mt-4">Delay</div>
<SecondsInput bind:seconds={flowModuleRetry.constant.seconds} />
{/if}
{:else if delayType === 'exponential'}
{#if flowModuleRetry?.exponential}
<div class="text-xs font-bold !mt-2">Attempts</div>
<div class="flex gap-1">
<input max="100" bind:value={flowModuleRetry.exponential.attempts} type="number" />
<button
class="text-xs"
onclick={() =>
flowModuleRetry?.exponential && (flowModuleRetry.exponential.attempts = 100)}
>max</button
>
</div>
<div class="text-xs font-bold !mt-2">Multiplier</div>
<span class="text-xs text-primary">delay = multiplier * base ^ (number of attempt)</span>
<input bind:value={flowModuleRetry.exponential.multiplier} type="number" />
<div class="text-xs font-bold !mt-2">Base (in seconds)</div>
<input
bind:value={flowModuleRetry.exponential.seconds}
type="number"
step="1"
min="0"
class={validationError ? 'border-red-500' : ''}
/>
{#if validationError}
<span class="text-xs text-red-500">{validationError}</span>
{:else}
<span class="text-xs text-tertiary"
>Must be ≥ 1. A base of 0 would cause immediate retries.</span
>
{/if}
<div class="text-xs font-bold !mt-2">Randomization factor (percentage)</div>
<div class="flex w-full gap-4">
{#if !$enterpriseLicense}
<EEOnly />
{/if}
<div class="text-xs font-bold !mt-2">Base (in seconds)</div>
<input
disabled={!$enterpriseLicense}
type="range"
min={0}
max={100}
step={5}
bind:value={flowModuleRetry.exponential.random_factor}
disabled={cfgDisabled}
bind:value={displayExponential.seconds}
type="number"
step="1"
min="0"
class={validationError ? 'border-red-500' : ''}
/>
<div class="w-20">
{#if validationError}
<span class="text-xs text-red-500">{validationError}</span>
{:else}
<span class="text-xs text-tertiary"
>Must be ≥ 1. A base of 0 would cause immediate retries.</span
>
{/if}
<div class="text-xs font-bold !mt-2">Randomization factor (percentage)</div>
<div class="flex w-full gap-4">
{#if !$enterpriseLicense}
<EEOnly />
{/if}
<input
disabled={true}
bind:value={flowModuleRetry.exponential.random_factor}
type="number"
step={5}
disabled={cfgDisabled || !$enterpriseLicense}
type="range"
min={0}
max={100}
step={5}
bind:value={displayExponential.random_factor}
/>
<div class="w-20">
<input
disabled={true}
bind:value={displayExponential.random_factor}
type="number"
step={5}
min={0}
max={100}
/>
</div>
</div>
</div>
{/if}
{/if}
</div>
<div class="w-1/2 h-full overflow-auto pl-2">
{#if true}
{@const { attempts: cAttempts, seconds: cSeconds } = flowModuleRetry?.constant || {}}
{@const {
attempts: eAttempts,
seconds: eSeconds,
multiplier,
random_factor
} = flowModuleRetry?.exponential || {}}
{@const cArray = Array.from({ length: Math.min(cAttempts || 0, 100) }, () => cSeconds)}
{@const eArray = Array.from(
{ length: Math.min(eAttempts || 0, 100) },
(_, i) => (multiplier || 0) * (eSeconds || 0) ** (i + cArray.length + 1)
)}
{@const array = [...cArray, ...eArray]}
<div class="bg-surface-secondary border rounded px-4 py-2">
<div class="text-xs font-medium mb-2">Retry attempts</div>
{#if array.length > 0}
<table class="text-xs">
<thead>
<tr>
<td class="font-semibold pr-1 pb-1">1:</td>
<td class="pb-1"
>After {array[0]} second{array[0] === 1 ? '' : 's'}
{#if (random_factor ?? 0) > 0}(+/- {((array[0] ?? 0) * (random_factor ?? 0)) /
100}
seconds){/if}</td
>
</tr>
</thead>
<tbody>
{#each array.slice(1, 100) as delay, i}
{@const index = i + 2}
<tr>
<td class="font-semibold pr-1 align-top">{index}:</td>
<td class="pb-1 whitespace-nowrap">
{delay} second{delay === 1 ? '' : 's'}
{#if (random_factor ?? 0) > 0}(+/- {((delay ?? 0) * (random_factor ?? 0)) /
100}
seconds){/if}
after attempt #{index - 1}
{#if i > cArray.length - 2}
<span class="text-gray-400 pl-2">
({multiplier} * {eSeconds}<sup>{index}</sup>)
</span>
{/if}
</div>
<div class="w-1/2 h-full overflow-auto pl-2">
{#if true}
{@const { attempts: cAttempts, seconds: cSeconds } = previewRetry?.constant || {}}
{@const {
attempts: eAttempts,
seconds: eSeconds,
multiplier,
random_factor
} = previewRetry?.exponential || {}}
{@const cArray = Array.from({ length: Math.min(cAttempts || 0, 100) }, () => cSeconds)}
{@const eArray = Array.from(
{ length: Math.min(eAttempts || 0, 100) },
(_, i) => (multiplier || 0) * (eSeconds || 0) ** (i + cArray.length + 1)
)}
{@const array = [...cArray, ...eArray]}
<div class="bg-surface-secondary border rounded px-4 py-2">
<div class="text-xs font-medium mb-2">Retry attempts</div>
{#if array.length > 0}
<table class="text-xs">
<thead>
<tr>
<td class="font-semibold pr-1 pb-1">1:</td>
<td class="pb-1"
>After {array[0]} second{array[0] === 1 ? '' : 's'}
{#if (random_factor ?? 0) > 0}(+/- {((array[0] ?? 0) *
(random_factor ?? 0)) /
100}
seconds){/if}</td
>
</tr>
</thead>
<tbody>
{#each array.slice(1, 100) as delay, i}
{@const index = i + 2}
<tr>
<td class="font-semibold pr-1 align-top">{index}:</td>
<td class="pb-1 whitespace-nowrap">
{delay} second{delay === 1 ? '' : 's'}
{#if (random_factor ?? 0) > 0}(+/- {((delay ?? 0) *
(random_factor ?? 0)) /
100}
seconds){/if}
after attempt #{index - 1}
{#if i > cArray.length - 2}
<span class="text-gray-400 pl-2">
({multiplier} * {eSeconds}<sup>{index}</sup>)
</span>
{/if}
</td>
</tr>
{/each}
{#if (cAttempts ?? 0) > 100 || (eAttempts ?? 0) > 100}
<tr>
<td class="font-semibold pr-1 align-top">...</td>
<td class="pb-1">...</td>
</tr>
{/if}
</td>
</tr>
{/each}
{#if (cAttempts ?? 0) > 100 || (eAttempts ?? 0) > 100}
<tr>
<td class="font-semibold pr-1 align-top">...</td>
<td class="pb-1">...</td>
</tr>
</tbody>
</table>
{/if}
</tbody>
</table>
{/if}
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,371 @@
<script lang="ts">
import { getContext } from 'svelte'
import { enterpriseLicense } from '$lib/stores'
import { isCloudHosted } from '$lib/cloud'
import type { FlowModule } from '$lib/gen'
import type { FlowEditorContext } from '../types'
import Toggle from '$lib/components/Toggle.svelte'
import Label from '$lib/components/Label.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { SecondsInput } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import s3Scripts from './s3Scripts/lib'
import FlowRetries from './FlowRetries.svelte'
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
import FlowModuleSkip from './FlowModuleSkip.svelte'
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
import FlowModuleSleep from './FlowModuleSleep.svelte'
import FlowModuleTimeout from './FlowModuleTimeout.svelte'
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
import FlowModuleCache from './FlowModuleCache.svelte'
import { hasInlineConcurrency, stepSettingDefaults } from '../flowStepSettings'
import FlowModuleDebounce from './FlowModuleDebounce.svelte'
import FlowModuleMock from './FlowModuleMock.svelte'
import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte'
import { slideDynamic } from '$lib/transitions'
const { pathStore } = getContext<FlowEditorContext>('FlowEditorContext')
interface Props {
flowModule: FlowModule
parentModule?: FlowModule | undefined
previousModule?: FlowModule | undefined
selectedId: string
/** Lay out for embedding inside another scroll container (no own scroll/padding). */
embedded?: boolean
/** Container steps (loops and branches): keep only the subset of settings they support
* (Flow control rows + Lifetime), hiding the rest of Execution policy. */
loopSubset?: boolean
/** Loops render the per-iteration break next to their own settings, so they ask
* for the all-iterations predicate only. */
earlyStopBlocks?: 'both' | 'all-iters'
// For workspace-script steps: the concurrency/cache settings currently set on
// the referenced script, and a shortcut to edit them. Undefined for
// inline/subflow steps.
referencedConcurrentLimit?: number | undefined
referencedConcurrencyTimeWindowS?: number | undefined
workspaceScriptCacheTtl?: number | undefined
loadingWorkspaceScript?: boolean
workspaceScriptError?: string | undefined
canEditWorkspaceScript?: boolean
workspaceScriptNoEditReason?: string | undefined
onEditWorkspaceScript?: () => void
/** Replace the inline script's code — provided by the step editor. */
onApplyS3Snippet?: (code: string) => void
/** Agent tools never go through the flow scheduler, so the settings `same_worker`
* rules out for a flow step still apply to them. */
isAgentTool?: boolean
}
let {
flowModule = $bindable(),
parentModule = undefined,
previousModule = undefined,
selectedId,
embedded = false,
loopSubset = false,
earlyStopBlocks = 'both',
referencedConcurrentLimit = undefined,
referencedConcurrencyTimeWindowS = undefined,
workspaceScriptCacheTtl = undefined,
loadingWorkspaceScript = false,
workspaceScriptError = undefined,
canEditWorkspaceScript = false,
workspaceScriptNoEditReason = undefined,
onEditWorkspaceScript,
onApplyS3Snippet,
isAgentTool = false
}: Props = $props()
let rootEl: HTMLDivElement | undefined = $state()
/** Scroll one setting into view. `key` is a StepSettingKey, so the AI tool's openTab
* intent must name settings with those keys. */
export function openSetting(key: string) {
rootEl?.querySelector(`[data-setting="${key}"]`)?.scrollIntoView({ block: 'nearest' })
}
// Exact id: only the flow's error handler is 'failure'; a step the user named
// something like handle_failure is an ordinary step and keeps every row.
const isFailure = $derived(selectedId === 'failure')
const isRawScript = $derived(flowModule.value.type === 'rawscript')
const isWorkspaceScript = $derived(flowModule.value.type === 'script')
const s3Language = $derived(
flowModule.value.type === 'rawscript' &&
(flowModule.value.language === 'python3' || flowModule.value.language === 'deno')
? flowModule.value.language
: undefined
)
let s3Kind = $state<'s3_client' | 'polars' | 'duckdb'>('s3_client')
const s3Snippet = $derived(s3Language ? s3Scripts[s3Language][s3Kind] : undefined)
const concurrencyOn = $derived(hasInlineConcurrency(flowModule))
const concurrencyOff = $derived(!$enterpriseLicense || !concurrencyOn)
</script>
{#snippet sectionHeader(title: string)}
<div class="text-[11px] font-medium uppercase tracking-[0.04em] text-hint">
{title}
</div>
{/snippet}
<div
bind:this={rootEl}
class="flex flex-col gap-8 {embedded ? '' : 'flex-1 min-h-0 overflow-auto p-4 pb-8'}"
style={embedded ? undefined : 'scrollbar-gutter: stable'}
>
{#if !isFailure}
<section class="flex flex-col gap-3">
{@render sectionHeader('Flow control')}
<div class="flex flex-col gap-6">
<div data-setting="skip">
<FlowModuleSkip bind:flowModule {parentModule} {previousModule} />
</div>
<div data-setting="early-stop">
<FlowModuleEarlyStop bind:flowModule blocks={earlyStopBlocks} />
</div>
<div data-setting="suspend">
<FlowModuleSuspend previousModuleId={previousModule?.id} bind:flowModule />
</div>
<div data-setting="sleep">
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule {isAgentTool} />
</div>
</div></section
>
{/if}
<section class="flex flex-col gap-3">
{@render sectionHeader('Execution policy')}
<div class="flex flex-col gap-6">
{#if !loopSubset}
<div data-setting="retries">
<FlowRetries bind:flowModuleRetry={flowModule.retry} bind:flowModule {isAgentTool} />
</div>
<div data-setting="error-handling">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
bind:checked={flowModule.continue_on_error}
options={{
right: 'Continue on error',
rightTooltip:
"The flow continues to the next step even if this step fails (after exhausting retries, if any). The step's error becomes its return, so a following branch can handle it."
}}
/>
</div>
<!-- The error handler runs outside the flow's control graph; only its own
failure handling applies. -->
{#if !isFailure}
<div data-setting="timeout">
<FlowModuleTimeout previousModuleId={previousModule?.id} bind:flowModule />
</div>
{#if isRawScript || isWorkspaceScript}
<div data-setting="concurrency">
{#if flowModule.value.type === 'script'}
<WorkspaceScriptSettingInfo
label="Concurrency limit"
active={referencedConcurrentLimit != undefined}
valueText={referencedConcurrentLimit != undefined
? `Max ${referencedConcurrentLimit} execution${
referencedConcurrentLimit === 1 ? '' : 's'
}${
referencedConcurrencyTimeWindowS != undefined
? ` within ${referencedConcurrencyTimeWindowS}s`
: ''
}`
: undefined}
loading={loadingWorkspaceScript}
error={workspaceScriptError}
canEdit={canEditWorkspaceScript}
noEditReason={workspaceScriptNoEditReason}
onEdit={onEditWorkspaceScript}
/>
{:else if flowModule.value.type === 'rawscript'}
<div class="flex flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
eeOnly
disabled={!$enterpriseLicense}
checked={concurrencyOn}
on:change={() => {
if (flowModule.value.type !== 'rawscript') return
flowModule.value.concurrent_limit = concurrencyOn ? undefined : 1
}}
options={{
right: 'Concurrency limit',
rightTooltip: 'Allowed concurrency within a given timeframe.',
rightDocumentationLink:
'https://www.windmill.dev/docs/flows/concurrency_limit'
}}
/>
{#if concurrencyOn}
<div class="flex flex-col gap-2 pl-9" transition:slideDynamic>
<Label label="Max number of executions within the time window">
<input
disabled={concurrencyOff}
bind:value={flowModule.value.concurrent_limit}
type="number"
min="1"
class="!w-24"
/>
</Label>
<Label label="Time window in seconds">
<SecondsInput
disabled={concurrencyOff}
bind:seconds={flowModule.value.concurrency_time_window_s}
clearable
/>
</Label>
<Label label="Custom concurrency key (optional)">
{#snippet header()}
<Tooltip>
Concurrency keys are global, you can have them be workspace specific
using the variable `$workspace`. You can also use an argument's value
using `$args[name_of_arg]`</Tooltip
>
{/snippet}
<input
type="text"
disabled={concurrencyOff}
bind:value={flowModule.value.custom_concurrency_key}
placeholder={`$workspace/script/${$pathStore}-$args[foo]`}
/>
</Label>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<div data-setting="priority" class="flex flex-col gap-2">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
eeOnly
disabled={!$enterpriseLicense || isCloudHosted()}
checked={flowModule.priority !== undefined}
on:change={() => {
if (flowModule.priority !== undefined) {
flowModule.priority = undefined
} else {
flowModule.priority = stepSettingDefaults('priority')
}
}}
options={{
right: 'High priority',
rightTooltip:
'Jobs scheduled from this step take precedence over other jobs in the queue when the flow runs.'
}}
/>
{#if flowModule.priority !== undefined}
<div class="pl-9" transition:slideDynamic>
<Label label="Priority number">
{#snippet header()}
<Tooltip>The higher the number, the higher the priority.</Tooltip>
{/snippet}
<input
type="number"
class="!w-24"
bind:value={flowModule.priority}
onchange={() => {
if (flowModule.priority && flowModule.priority > 100) {
flowModule.priority = 100
} else if (flowModule.priority && flowModule.priority < 0) {
flowModule.priority = 0
}
}}
/>
</Label>
</div>
{/if}
<!-- The EE half of this limitation is the toggle's own badge; only the
cloud one needs saying. -->
{#if isCloudHosted()}
<Alert type="warning" title="Limitation" size="xs">
Setting priority is not available on the cloud.
</Alert>
{/if}
</div>
<div data-setting="cache">
<FlowModuleCache
bind:flowModule
{workspaceScriptCacheTtl}
{loadingWorkspaceScript}
{workspaceScriptError}
{canEditWorkspaceScript}
{workspaceScriptNoEditReason}
{onEditWorkspaceScript}
/>
</div>
<div data-setting="debounce">
<FlowModuleDebounce bind:flowModule {selectedId} />
</div>
{/if}
{/if}
{#if !isFailure}
<div data-setting="lifetime">
<FlowModuleDeleteAfterUse bind:flowModule disabled={!$enterpriseLicense} />
</div>
{/if}
{#if loopSubset}
<div data-setting="mock">
<FlowModuleMock bind:flowModule />
</div>
{/if}
</div></section
>
{#if s3Language && onApplyS3Snippet && !isFailure}
<section class="flex flex-col gap-3" data-setting="s3">
{@render sectionHeader('S3 snippets')}
<p class="text-xs text-tertiary">
Read and write S3 objects, and use Polars or DuckDB to run efficient ETL processes.
</p>
<div class="flex flex-row items-center justify-between gap-2">
<ToggleButtonGroup bind:selected={s3Kind} class="w-auto">
{#snippet children({ item })}
{#if s3Language === 'deno'}
<ToggleButton value="s3_client" small label="S3 lite client" {item} />
{:else}
<ToggleButton value="s3_client" small label="Boto3" {item} />
<ToggleButton value="polars" small label="Polars" {item} />
<ToggleButton value="duckdb" small label="DuckDB" {item} />
{/if}
{/snippet}
</ToggleButtonGroup>
<Button
size="xs"
variant="default"
on:click={() => s3Snippet && onApplyS3Snippet?.(s3Snippet)}
>
Apply snippet
</Button>
</div>
{#if s3Snippet}
<div class="overflow-auto max-h-64 border rounded-md">
<HighlightCode language={s3Language} code={s3Snippet} />
</div>
{/if}
</section>
{/if}
</div>
@@ -116,7 +116,7 @@
<div class="h-full flex flex-col">
<FlowCard {noEditor} title="Settings">
<div class="grow min-h-0 p-4 h-full flex flex-col gap-6">
<div class="grow min-h-0 p-4 h-full flex flex-col gap-6 overflow-y-auto">
<!-- Metadata Section -->
<div class="gap-6 flex flex-col">
<Label label="Summary">
@@ -188,7 +188,7 @@
label="Advanced"
collapsable={true}
small={true}
class="h-full grow mt-2 min-h-0 flex flex-col gap-6"
class="h-full grow mt-2 min-h-0 flex flex-col gap-6 pb-2"
>
<!-- Worker Group Section -->
{#if customUi?.settingsTabs?.workerGroup != false}
@@ -626,11 +626,10 @@
flowStore.val.value.priority = 100
}
}}
eeOnly={true}
options={{
right: `Label as high priority`,
rightTooltip: `All jobs scheduled by flows labeled as high priority take precedence over the other jobs in the jobs queue. Higher priority numbers are executed first. ${
!$enterpriseLicense ? 'This is a feature only available on enterprise edition.' : ''
}`,
rightTooltip: `All jobs scheduled by flows labeled as high priority take precedence over the other jobs in the jobs queue. Higher priority numbers are executed first.`,
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/priority'
}}
>
@@ -653,9 +652,6 @@
}
}}
/>
{#if !$enterpriseLicense || isCloudHosted()}
<EEOnly />
{/if}
{/snippet}
</Toggle>
@@ -673,7 +669,7 @@
}}
options={{
right: 'Delete all step results after completion',
rightTooltip: `When enabled, the logs, arguments and results of all flow steps will be deleted after the specified delay once the flow completes. Set to 0 for immediate deletion. The deletion is irreversible. ${!$enterpriseLicense ? 'This is a feature only available on enterprise edition.' : ''}`
rightTooltip: `When enabled, the logs, arguments and results of all flow steps will be deleted after the specified delay once the flow completes. Set to 0 for immediate deletion. The deletion is irreversible.`
}}
eeOnly={true}
/>
@@ -1,24 +1,16 @@
<script lang="ts">
import { getContext } from 'svelte'
import { getContext, tick } from 'svelte'
import FlowCard from '../common/FlowCard.svelte'
import type { FlowEditorContext } from '../types'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
import FlowModuleSuspend from './FlowModuleSuspend.svelte'
// import FlowRetries from './FlowRetries.svelte'
import { Button, Drawer, Tab, TabContent, Alert } from '$lib/components/common'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { enterpriseLicense } from '$lib/stores'
import { Button, Drawer, Alert, Tab, Tabs } from '$lib/components/common'
import FlowModuleSleep from './FlowModuleSleep.svelte'
import FlowModuleMock from './FlowModuleMock.svelte'
import { Play } from 'lucide-svelte'
import type { FlowModule, Job, WhileloopFlow } from '$lib/gen'
import FlowLoopIterationPreview from '$lib/components/FlowLoopIterationPreview.svelte'
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
import FlowModuleSkip from './FlowModuleSkip.svelte'
import TabsV2 from '$lib/components/common/tabs/TabsV2.svelte'
import FlowRunSettings from './FlowRunSettings.svelte'
import StepSettingsBadges from './StepSettingsBadges.svelte'
import FlowModuleEarlyStop from './FlowModuleEarlyStop.svelte'
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
const { flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -32,7 +24,7 @@
let { mod = $bindable(), previousModule, parentModule, noEditor }: Props = $props()
let selected: string = $state('early-stop')
let runSettings: FlowRunSettings | undefined = $state(undefined)
let previewOpen = $state(false)
let jobId: string | undefined = $state(undefined)
@@ -40,9 +32,15 @@
let previewIterationArgs = $derived(flowStateStore.val[mod.id]?.previewArgs ?? {})
let selectedTab = $state('loop')
useUiIntent(`whileloopflow-${mod.id}`, {
openTab: (tab) => {
selected = tab
openTab: async (tab) => {
// Every setting the intent can name lives in the other tab, which only mounts
// `runSettings` once selected.
selectedTab = 'settings'
await tick()
runSettings?.openSetting(tab)
}
})
</script>
@@ -64,133 +62,94 @@
<FlowCard {noEditor} title="While loop">
{#snippet header()}
<div class="grow">
<input bind:value={mod.summary} placeholder={'Summary'} />
<div class="flex flex-row gap-2 items-center">
<div class="grow">
<input bind:value={mod.summary} placeholder={'Summary'} />
</div>
<div class="justify-end">
<Button
on:click={() => (previewOpen = true)}
startIcon={{ icon: Play }}
variant="default"
size="sm">Test an iteration</Button
>
</div>
</div>
</div>
{/snippet}
<Splitpanes horizontal class="h-full">
<Pane size={50} minSize={20} class="p-4">
{#if !noEditor}
<Alert
type="info"
title="While loops"
class="mb-4"
size="xs"
documentationLink="https://www.windmill.dev/docs/flows/while_loops"
>
Add steps inside the while loop but have one of them use early stop/break in their
Advanced settings (or do it at the loop level that will watch the last step) to break out
of the while loop (otherwise it will loop forever and you will have to cancel the flow
manually).
</Alert>
{/if}
<div class="flex h-full min-h-0 flex-col">
{#if mod.value.type === 'whileloopflow'}
<Tabs bind:selected={selectedTab} wrapperClass="shrink-0">
<Tab value="loop" label="Loop" />
<Tab value="settings" label="Run settings">
{#snippet extra()}
<StepSettingsBadges flowModule={mod} />
{/snippet}
</Tab>
</Tabs>
{#if mod.value.type === 'whileloopflow'}
<div class="flex flex-row gap-8 mt-2 mb-6">
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold"
>Skip failures <Tooltip
documentationLink="https://www.windmill.dev/docs/flows/while_loops"
>If disabled, the flow will fail as soon as one of the iteration fail. Otherwise,
the error will be collected as the result of the iteration. Regardless of this
setting, if a flow level error handler is defined, it will process the error.
(Workspace error handlers will NOT be used to process errors if enabled.)</Tooltip
></div
<div
class="flex min-h-0 flex-1 flex-col gap-8 overflow-auto p-4"
style="scrollbar-gutter: stable"
>
{#if selectedTab === 'loop'}
{#if !noEditor}
<Alert
type="info"
title="While loops"
size="xs"
documentationLink="https://www.windmill.dev/docs/flows/while_loops"
>
Add steps inside the while loop but have one of them use early stop/break in their Run
settings (or do it at the loop level that will watch the last step) to break out of
the while loop (otherwise it will loop forever and you will have to cancel the flow
manually).
</Alert>
{/if}
<section class="flex flex-col gap-6">
<FlowModuleEarlyStop blocks="stop-after" bind:flowModule={mod} />
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
bind:checked={mod.value.skip_failures}
options={{
right: 'Skip failures'
right: 'Skip failures',
rightTooltip:
'If disabled, the flow will fail as soon as one of the iteration fail. Otherwise, the error will be collected as the result of the iteration. Regardless of this setting, if a flow level error handler is defined, it will process the error. (Workspace error handlers will NOT be used to process errors if enabled.)',
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/while_loops'
}}
/>
</div>
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold"
>Squash
<Tooltip documentationLink="https://www.windmill.dev/docs/flows/while_loops">
Squashing a while loop runs all iterations on the same worker, using a single runner
per step for the entire loop. This eliminates cold starts between iterations for
supported languages (Bun, Deno, and Python).
</Tooltip>
</div>
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
bind:checked={mod.value.squash}
on:change={({ detail }) => {
;(mod.value as WhileloopFlow).squash = detail
}}
options={{
right: 'Squash'
right: 'Squash',
rightTooltip:
'Squashing a while loop runs all iterations on the same worker, using a single runner per step for the entire loop. This eliminates cold starts between iterations for supported languages (Bun, Deno, and Python).',
rightDocumentationLink: 'https://www.windmill.dev/docs/flows/while_loops'
}}
class="whitespace-nowrap"
/>
</div>
</div>
<div class="my-2 flex flex-row gap-2 items-center">
<div class="flex w-full justify-end">
<Button
on:click={() => (previewOpen = true)}
startIcon={{ icon: Play }}
variant="accent"
size="sm">Test an iteration</Button
>
</div>
</div>
{/if}
</Pane>
<Pane size={40} minSize={20} class="flex flex-col flex-1">
<TabsV2 bind:selected>
<!-- <Tab value="retries">Retries</Tab> -->
<Tab value="early-stop" label="Early Stop/Break" />
<Tab value="skip" label="Skip" />
<Tab value="suspend" label="Suspend/Approval/Prompt" />
<Tab value="sleep" label="Sleep" />
<Tab value="mock" label="Mock" />
<Tab value="lifetime" label="Lifetime" />
{#snippet content()}
<div class="overflow-hidden bg-surface" style="height:calc(100% - 32px);">
<!-- <TabContent value="retries" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowRetries bind:flowModule={mod} />
</div>
</TabContent> -->
<TabContent value="early-stop" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleEarlyStop bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="skip" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSkip bind:flowModule={mod} {parentModule} {previousModule} />
</div>
</TabContent>
<TabContent value="suspend" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSuspend previousModuleId={previousModule?.id} bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="sleep" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="mock" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleMock bind:flowModule={mod} />
</div>
</TabContent>
<TabContent value="lifetime" class="flex flex-col flex-1 h-full">
<div class="p-4 overflow-y-auto">
<FlowModuleDeleteAfterUse bind:flowModule={mod} disabled={!$enterpriseLicense} />
</div>
</TabContent>
</div>
{/snippet}
</TabsV2>
</Pane>
</Splitpanes>
</section>
{:else}
<FlowRunSettings
embedded
loopSubset
earlyStopBlocks="all-iters"
bind:this={runSettings}
bind:flowModule={mod}
{parentModule}
{previousModule}
selectedId={mod.id}
/>
{/if}
</div>
{/if}
</div>
</FlowCard>
@@ -18,6 +18,7 @@
<script lang="ts">
import type { McpTool } from '../agentToolUtils'
import FlowCard from '../common/FlowCard.svelte'
import Section from '$lib/components/Section.svelte'
import Label from '$lib/components/Label.svelte'
import { Button } from '$lib/components/common'
@@ -33,9 +34,10 @@
interface Props {
tool: McpTool
noEditor?: boolean
}
let { tool = $bindable() }: Props = $props()
let { tool = $bindable(), noEditor = false }: Props = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
@@ -92,124 +94,126 @@
}
</script>
<div class="flex flex-col gap-4 p-4">
<Alert type="info" title="MCP Client Configuration">
{#snippet children()}
<p class="mb-2 text-sm">
MCP clients allow AI agents to access and execute a list of tools made available by an MCP
server.
<br />
Choose an MCP resource to make its tools available to the agent.
<br />
<br />
<strong>Note:</strong> Only HTTP streamable MCP servers are supported.
</p>
{/snippet}
</Alert>
<FlowCard {noEditor} title="MCP tool">
<div class="flex flex-col gap-4 overflow-auto p-4" style="scrollbar-gutter: stable">
<Alert type="info" title="MCP Client Configuration">
{#snippet children()}
<p class="mb-2 text-sm">
MCP clients allow AI agents to access and execute a list of tools made available by an MCP
server.
<br />
Choose an MCP resource to make its tools available to the agent.
<br />
<br />
<strong>Note:</strong> Only HTTP streamable MCP servers are supported.
</p>
{/snippet}
</Alert>
<div class="w-full">
<Label label="MCP Resource">
<ResourcePicker
bind:this={resourcePicker}
resourceType="mcp"
bind:value={tool.value.resource_path}
workspace={opWs}
/>
</Label>
</div>
{#if !resourcePath}
{#if !showOAuthForm}
<Button size="xs" color="light" onClick={() => (showOAuthForm = true)}>
Connect with OAuth
</Button>
{:else}
<McpOAuthConnect
onConnected={handleOAuthConnected}
onCancel={() => (showOAuthForm = false)}
/>
{/if}
{/if}
{#if resourcePath?.length > 0}
<div class="w-full">
<Label label="Summary">
<input
type="text"
bind:value={tool.summary}
placeholder="e.g., GitHub MCP"
class="text-sm w-full"
<Label label="MCP Resource">
<ResourcePicker
bind:this={resourcePicker}
resourceType="mcp"
bind:value={tool.value.resource_path}
workspace={opWs}
/>
</Label>
</div>
<Section label="Available Tools">
{#snippet action()}
<Button
size="xs"
color="light"
onClick={() => (refreshCount += 1)}
startIcon={{ icon: RefreshCw }}
disabled={tools.status === 'loading'}
>
{tools.status === 'loading' ? 'Loading...' : 'Refresh Tools'}
{#if !resourcePath}
{#if !showOAuthForm}
<Button size="xs" color="light" onClick={() => (showOAuthForm = true)}>
Connect with OAuth
</Button>
{/snippet}
<div class="w-full flex flex-col gap-2">
{#if error}
<div class="text-xs text-red-600 dark:text-red-400 mb-4"
>{`Failed to load tools from MCP server: ${error}`}</div
>
{:else if tools.status === 'loading'}
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
<div class="text-xs text-secondary italic">Loading tools...</div>
</div>
{:else if (tools.value ?? []).length === 0 && !error}
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
<div class="text-xs text-secondary italic">
No tools loaded yet. Click "Refresh Tools" to fetch tools from the MCP server.
</div>
</div>
{:else if (tools.value ?? []).length > 0}
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
<div class="flex flex-col gap-1">
{#each tools.value ?? [] as mcpTool}
<div class="text-xs">
<span class="font-semibold">{mcpTool.name}</span>
{#if mcpTool.description}
<span class="text-secondary">{mcpTool.description}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
</Section>
{:else}
<McpOAuthConnect
onConnected={handleOAuthConnected}
onCancel={() => (showOAuthForm = false)}
/>
{/if}
{/if}
{#if tool.value.include_tools && tool.value.exclude_tools}
<Section label="Tool Filtering">
<div class="w-full flex flex-col gap-3">
<div class="flex flex-col gap-2">
<Label label="Only include specified tools">
<MultiSelect
bind:value={tool.value.include_tools}
items={toolOptions}
placeholder="Choose tools to include..."
/>
</Label>
</div>
<div class="flex flex-col gap-2">
<Label label="Exclude specified tools">
<MultiSelect
bind:value={tool.value.exclude_tools}
items={toolOptions}
placeholder="Choose tools to exclude..."
/>
</Label>
</div>
{#if resourcePath?.length > 0}
<div class="w-full">
<Label label="Summary">
<input
type="text"
bind:value={tool.summary}
placeholder="e.g., GitHub MCP"
class="text-sm w-full"
/>
</Label>
</div>
<Section label="Available Tools">
{#snippet action()}
<Button
size="xs"
color="light"
onClick={() => (refreshCount += 1)}
startIcon={{ icon: RefreshCw }}
disabled={tools.status === 'loading'}
>
{tools.status === 'loading' ? 'Loading...' : 'Refresh Tools'}
</Button>
{/snippet}
<div class="w-full flex flex-col gap-2">
{#if error}
<div class="text-xs text-red-600 dark:text-red-400 mb-4"
>{`Failed to load tools from MCP server: ${error}`}</div
>
{:else if tools.status === 'loading'}
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
<div class="text-xs text-secondary italic">Loading tools...</div>
</div>
{:else if (tools.value ?? []).length === 0 && !error}
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
<div class="text-xs text-secondary italic">
No tools loaded yet. Click "Refresh Tools" to fetch tools from the MCP server.
</div>
</div>
{:else if (tools.value ?? []).length > 0}
<div class="max-h-48 overflow-y-auto border rounded p-2 bg-surface-secondary">
<div class="flex flex-col gap-1">
{#each tools.value ?? [] as mcpTool}
<div class="text-xs">
<span class="font-semibold">{mcpTool.name}</span>
{#if mcpTool.description}
<span class="text-secondary">{mcpTool.description}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
</Section>
{#if tool.value.include_tools && tool.value.exclude_tools}
<Section label="Tool Filtering">
<div class="w-full flex flex-col gap-3">
<div class="flex flex-col gap-2">
<Label label="Only include specified tools">
<MultiSelect
bind:value={tool.value.include_tools}
items={toolOptions}
placeholder="Choose tools to include..."
/>
</Label>
</div>
<div class="flex flex-col gap-2">
<Label label="Exclude specified tools">
<MultiSelect
bind:value={tool.value.exclude_tools}
items={toolOptions}
placeholder="Choose tools to exclude..."
/>
</Label>
</div>
</div>
</Section>
{/if}
{/if}
{/if}
</div>
</div>
</FlowCard>
@@ -0,0 +1,30 @@
<script lang="ts">
import Popover from '$lib/components/Popover.svelte'
import type { FlowModule } from '$lib/gen'
import { describeStepSettings } from '../flowStepSettings'
interface Props {
flowModule: FlowModule
}
let { flowModule }: Props = $props()
// Same icons the graph badges a step with, so a setting looks the same wherever it
// is surfaced.
let configured = $derived(describeStepSettings(flowModule).filter((s) => s.configured))
</script>
{#if configured.length > 0}
<div class="flex flex-row items-center gap-1 text-secondary">
{#each configured as s (s.key)}
{@const Icon = s.icon}
<Popover notClickable>
<Icon size={12} />
{#snippet text()}
{s.tooltip}
<span class={s.summary.mono ? 'font-mono' : ''}>· {s.summary.text}</span>
{/snippet}
</Popover>
{/each}
</div>
{/if}
@@ -16,21 +16,26 @@
</script>
<Button
size="xs"
variant="default"
unifiedSize="xs"
variant="subtle"
btnClasses="text-hint font-normal"
endIcon={{ icon: HelpCircle }}
on:click={() => {
drawer?.openDrawer()
}}
>{text} <HelpCircle size={12} />
>
{text}
</Button>
<Drawer bind:this={drawer}>
<DrawerContent title="Suspend/Approval/Prompt help" on:close={drawer?.closeDrawer}>
<div class="flex flex-col gap-y-6 text-xs text-primary font-normal">
<Section label="Form/Payload">
To add a form, go to the <b>Form</b> tab, inside the Advanced {'->'} Suspend tab, and add a form.
You can then get back the payloads using `resume` (single approver), or `resumes` (multiple approvers)
in the next step. Forms are an EE feature only. The approver list itself is fetchable using `approvers`
To add a form, open the <b>Form</b> tab of this <b>Suspend until approval/resume</b> setting
and click
<b>Add a form</b>. You can then get back the payloads using `resume` (single approver), or
`resumes` (multiple approvers) in the next step. Forms are an EE feature only. The approver
list itself is fetchable using `approvers`
</Section>
<Section label="Prompt">
A prompt is simply an approval step that can be self-approved. To do this, include the
@@ -1,10 +1,15 @@
<script lang="ts">
import Alert from '$lib/components/common/alert/Alert.svelte'
import FlowCard from '../common/FlowCard.svelte'
let { noEditor = false }: { noEditor?: boolean } = $props()
</script>
<div class="flex flex-col gap-4 p-4">
<Alert title="Web search tool" type="info">
Gives the AI Agent the ability to search the web. Only works for openai, anthropic and google
models for now.
</Alert>
</div>
<FlowCard {noEditor} title="Web search">
<div class="flex flex-col gap-4 overflow-auto p-4" style="scrollbar-gutter: stable">
<Alert title="Web search tool" type="info">
Gives the AI Agent the ability to search the web. Only works for openai, anthropic and google
models for now.
</Alert>
</div>
</FlowCard>
@@ -134,7 +134,11 @@ describe('flowDeleteController', () => {
expect(flowStore.val.value.modules.map((module) => module.id)).toEqual(['dependent_step'])
expect(flowStore.val.value.groups ?? []).toEqual([])
expect(Object.keys(flowStateStore.val)).toEqual(['dependent_step'])
expect(selectionManager.selectId).toHaveBeenCalledWith('dependent_step')
// The surviving step is selected as a side effect of the delete, so it must be
// marked as such — otherwise the modal step panel pops open on its own.
expect(selectionManager.selectId).toHaveBeenCalledWith('dependent_step', {
openPanel: false
})
expect(onDelete).toHaveBeenCalledWith('agent_step')
})
})
@@ -56,7 +56,9 @@ export function executeDeletePlan(
if (plan.selection.kind === 'clear') {
args.selectionManager.clearSelection()
} else {
args.selectionManager.selectId(plan.selection.id)
// Whatever remains selected after a delete was not asked for, so it must not
// pop the modal panel open.
args.selectionManager.selectId(plan.selection.id, { openPanel: false })
}
if (plan.targets.some((target) => target.kind === 'preprocessor')) {
@@ -0,0 +1,28 @@
import { resolvePanelMode, type FlowPanelMode, type FlowPanelPreference } from './panelPlacement'
/**
* Holds the step panel's placement preference and the editor's measured width, and reads
* the resolution off `resolvePanelMode`. The preference is not persisted: it lasts as long
* as the editor is open, so every flow opens on `auto` and a pin is a deliberate act each
* time.
*/
export function useFlowPanelMode(opts: { enabled: () => boolean }) {
let preference = $state<FlowPanelPreference>('auto')
let width = $state(0)
return {
get preference(): FlowPanelPreference {
return preference
},
set preference(next: FlowPanelPreference) {
preference = next
},
get mode(): FlowPanelMode {
return resolvePanelMode({ enabled: opts.enabled(), preference, width })
},
/** Fed by the editor root's measured width; drives `auto` in both directions. */
measure(measured: number | null | undefined) {
width = measured ?? 0
}
}
}
@@ -0,0 +1,140 @@
import { describe, it, expect } from 'vitest'
import type { FlowModule } from '$lib/gen'
import { describeStepSettings, hasInlineConcurrency } from './flowStepSettings'
const stepSettingsByKey = (...args: Parameters<typeof describeStepSettings>) =>
Object.fromEntries(describeStepSettings(...args).map((v) => [v.key, v]))
function step(overrides: Partial<FlowModule> = {}): FlowModule {
return {
id: 'a',
value: { type: 'rawscript', language: 'bun', content: '', input_transforms: {} },
...overrides
} as FlowModule
}
describe('describeStepSettings', () => {
it('reports an untouched step as configured nowhere', () => {
expect(describeStepSettings(step()).filter((s) => s.configured)).toEqual([])
})
it('treats a sleep of 0 as configured and says so, rather than claiming it is off', () => {
// Switching Sleep on seeds `{ value: 0 }`; the row must not contradict the toggle.
const s = stepSettingsByKey(step({ sleep: { type: 'static', value: 0 } }))['sleep']
expect(s?.configured).toBe(true)
expect(s?.summary.text).toBe('0s after')
expect(s?.summary.state).toBe('configured')
})
it('does not let an empty stop_after_if mask a configured stop_after_all_iters_if', () => {
// Both can be set on a sequential loop.
const s = stepSettingsByKey(
step({
stop_after_if: { expr: '', skip_if_stopped: false },
stop_after_all_iters_if: { expr: 'result.done', skip_if_stopped: false }
})
)['early-stop']
expect(s?.configured).toBe(true)
expect(s?.summary.text).toBe('result.done')
})
it('describes retries with zero attempts instead of reporting None', () => {
const s = stepSettingsByKey(step({ retry: { constant: { attempts: 0, seconds: 5 } } }))[
'retries'
]
expect(s?.configured).toBe(true)
expect(s?.summary.text).toBe('0 attempts, constant')
})
it('reads concurrency and cache from the referenced script for workspace-script steps', () => {
const mod = step({ value: { type: 'script', path: 'u/me/s', input_transforms: {} } as any })
const off = stepSettingsByKey(mod)
expect(off['concurrency']?.configured).toBe(false)
const on = stepSettingsByKey(mod, { concurrent_limit: 3, cache_ttl: 60 })
expect(on['concurrency']?.configured).toBe(true)
expect(on['concurrency']?.summary.text).toBe('Max 3')
expect(on['cache']?.configured).toBe(true)
})
it("prefers the module's own cache_ttl over the referenced script's, like the worker", () => {
const mod = step({
cache_ttl: 3600,
value: { type: 'script', path: 'u/me/s', input_transforms: {} } as any
})
// No referenced settings loaded yet (the graph badges never load them), so a
// module-level TTL has to stand on its own here.
expect(stepSettingsByKey(mod)['cache']?.configured).toBe(true)
expect(stepSettingsByKey(mod, { cache_ttl: 60 })['cache']?.summary.text).toBe('1 h')
})
it('reports a non-positive inline concurrency as invalid, not as unset', () => {
const s = stepSettingsByKey(
step({
cache_ttl: -1,
value: {
type: 'rawscript',
language: 'bun',
content: '',
input_transforms: {},
concurrent_limit: -1
}
} as Partial<FlowModule>)
)
// `configured` means the step carries the config, so a value the user set counts
// even when the runtime ignores it — the summary is what says it is a no-op.
expect(s['concurrency']?.configured).toBe(true)
expect(s['concurrency']?.summary).toMatchObject({ text: 'Invalid limit', state: 'invalid' })
expect(s['cache']?.configured).toBe(true)
expect(s['cache']?.summary).toMatchObject({ text: 'No TTL set', state: 'invalid' })
})
it('treats a cleared concurrency input as present, not as unset', () => {
// Emptying a number input binds `null`, not `undefined`. Reading that as unset is
// what disabled the field the user was editing, so presence must be strict.
const mod = step({
value: {
type: 'rawscript',
language: 'bun',
content: '',
input_transforms: {},
concurrent_limit: null
}
} as unknown as Partial<FlowModule>)
// Presence keeps the setting editor's controls live while the field is empty.
expect(hasInlineConcurrency(mod)).toBe(true)
expect(stepSettingsByKey(mod)['concurrency']?.summary).toMatchObject({
text: 'Invalid limit',
state: 'invalid'
})
})
it('omits settings that do not apply to the step type', () => {
const subflow = step({ value: { type: 'flow', path: 'u/me/f' } as any })
expect(describeStepSettings(subflow).some((s) => s.key === 'concurrency')).toBe(false)
expect(describeStepSettings(step()).some((s) => s.key === 'concurrency')).toBe(true)
})
it('marks an invalid retry config as invalid rather than configured', () => {
const s = stepSettingsByKey(
step({ retry: { exponential: { attempts: 2, multiplier: 1, seconds: -1 } } })
)['retries']
expect(s?.summary.state).toBe('invalid')
})
it('labels early stop for trigger steps by what it means there', () => {
const trigger = step({
value: {
type: 'rawscript',
language: 'bun',
content: '',
input_transforms: {},
is_trigger: true
} as any
})
expect(stepSettingsByKey(trigger)['early-stop']?.tooltip).toBe(
'Stop early if there are no new events'
)
expect(stepSettingsByKey(step())['early-stop']?.tooltip).toBe('Early stop / break')
})
})
@@ -0,0 +1,362 @@
import {
ChevronsUp,
CircleStop,
Combine,
Database,
Gauge,
Hand,
Moon,
RefreshCw,
ShieldAlert,
SkipForward,
Timer,
Trash2
} from 'lucide-svelte'
import type { FlowModule } from '$lib/gen'
import type { ScriptAdvancedSettingsFields } from '$lib/components/scriptSettings'
import { validateRetryConfig } from '$lib/utils'
// Single source for the per-step runtime settings: which apply, which are configured,
// and how each reads back. Graph badges, the run-settings accordion and the setting
// editors all read it here so they cannot drift. Script-level twin: `scriptSettings.ts`.
export type StepSettingKey =
| 'skip'
| 'early-stop'
| 'suspend'
| 'sleep'
| 'retries'
| 'error-handling'
| 'timeout'
| 'concurrency'
| 'priority'
| 'cache'
| 'debounce'
| 'lifetime'
export type StepSettingSummary = {
text: string
state: 'configured' | 'default' | 'invalid'
/** Render the text as code (it is a user-written expression). */
mono?: boolean
}
export type StepSettingView = {
key: StepSettingKey
label: string
/** Longer wording for hover surfaces; falls back to `label`. */
tooltip: string
icon: any
/** The setting's config is present on the step. Deliberately not "the runtime
* would behave differently" a configured setting can still be a no-op
* (a sleep of 0), and `summary` says so rather than claiming it is off. */
configured: boolean
summary: StepSettingSummary
}
/** A step whose script polls an external system and returns the new items. */
export function isTriggerStep(module: FlowModule | undefined): boolean {
return (
module?.value != undefined &&
(module.value.type === 'script' || module.value.type === 'rawscript') &&
module.value.is_trigger === true
)
}
const def = (text: string): StepSettingSummary => ({ text, state: 'default' })
const inv = (text: string): StepSettingSummary => ({ text, state: 'invalid' })
const cfg = (text: string, mono = false): StepSettingSummary => ({
text,
state: 'configured',
mono
})
function formatDur(s: number | undefined): string {
if (s == null) return ''
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.round(s / 60)} min`
return `${Math.round(s / 3600)} h`
}
/** Describe a user-written predicate. An empty expression is still configured
* the setting is on, it just has nothing to evaluate yet. */
function exprSummary(expr: string | undefined): StepSettingSummary {
const e = expr?.trim()
if (!e) return cfg('No expression')
return e.length <= 24 ? cfg(e, true) : cfg('Expression set')
}
type Ctx = { referenced?: ScriptAdvancedSettingsFields }
type SettingSpec = {
label: string
tooltip?: (mod: FlowModule) => string
icon: any
applies?: (mod: FlowModule) => boolean
configured: (mod: FlowModule, ctx: Ctx) => boolean
summarize: (mod: FlowModule, ctx: Ctx) => StepSettingSummary
}
// A value the step itself carries counts as configured even when the runtime would
// ignore it — `summary` is what reports the no-op, and the setting editors keep their
// controls live on presence so clearing a field mid-edit can't disable it. Only values
// read off a referenced workspace script use the runtime's `> 0` test: nobody is typing
// into those, and 0 there simply means the script has no limit.
const isWorkspaceScript = (mod: FlowModule) => mod.value.type === 'script'
const inlineConcurrentLimit = (mod: FlowModule) =>
mod.value.type === 'rawscript' ? mod.value.concurrent_limit : undefined
const effectiveCacheTtl = (mod: FlowModule, ctx: Ctx) =>
mod.cache_ttl ?? (isWorkspaceScript(mod) ? ctx.referenced?.cache_ttl : undefined)
/** Whether an inline step carries a concurrency limit at all. The setting editor keeps its
* controls live on presence, so clearing the field mid-edit can't disable the input. A
* present-but-non-positive limit is surfaced as invalid rather than silently as "None".
* Strict: an emptied number input binds to `null`, which is still a value being typed. */
export function hasInlineConcurrency(mod: FlowModule): boolean {
return inlineConcurrentLimit(mod) !== undefined
}
/** Canonical order — every surface lists settings in this sequence. */
const SPECS: { key: StepSettingKey; spec: SettingSpec }[] = [
{
key: 'skip',
spec: {
label: 'Skip if',
icon: SkipForward,
configured: (m) => Boolean(m.skip_if),
summarize: (m) => (m.skip_if ? exprSummary(m.skip_if.expr) : def('Off'))
}
},
{
key: 'early-stop',
spec: {
label: 'Early stop / break',
tooltip: (m) =>
isTriggerStep(m) ? 'Stop early if there are no new events' : 'Early stop / break',
icon: CircleStop,
configured: (m) => m.stop_after_if != undefined || m.stop_after_all_iters_if != undefined,
summarize: (m) => {
// Both can be set on a sequential loop, so pick the first that carries an
// expression instead of letting an empty one mask the other.
const exprs = [m.stop_after_if?.expr, m.stop_after_all_iters_if?.expr].filter(
(e) => e != undefined
)
if (exprs.length === 0) return def('Off')
return exprSummary(exprs.find((e) => e?.trim()) ?? exprs[0])
}
}
},
{
key: 'suspend',
spec: {
label: 'Suspend until approval/resume',
icon: Hand,
configured: (m) => Boolean(m.suspend),
summarize: (m) => {
if (!m.suspend) return def('Off')
const n = m.suspend.required_events ?? 1
return cfg(`${n} approval${n > 1 ? 's' : ''}`)
}
}
},
{
key: 'sleep',
spec: {
label: 'Sleep',
icon: Moon,
configured: (m) => Boolean(m.sleep),
summarize: (m) => {
const s = m.sleep
if (!s) return def('Off')
if (s.type === 'static') {
const v = Number(s.value)
return Number.isFinite(v) ? cfg(`${formatDur(v)} after`) : cfg('Dynamic')
}
return cfg('Dynamic')
}
}
},
{
key: 'retries',
spec: {
label: 'Retries',
icon: RefreshCw,
configured: (m) => m.retry?.constant != undefined || m.retry?.exponential != undefined,
summarize: (m) => {
const r = m.retry
if (r?.constant == undefined && r?.exponential == undefined) return def('None')
if (validateRetryConfig(r)) return { text: 'Invalid', state: 'invalid' }
const isConstant = r?.constant != undefined
const n = (isConstant ? r?.constant?.attempts : r?.exponential?.attempts) ?? 0
const kind = isConstant ? 'constant' : 'exponential'
return cfg(`${n} attempt${n === 1 ? '' : 's'}, ${kind}`)
}
}
},
{
key: 'error-handling',
spec: {
label: 'Error handling',
icon: ShieldAlert,
configured: (m) => Boolean(m.continue_on_error),
summarize: (m) => (m.continue_on_error ? cfg('Continue on error') : def('Off'))
}
},
{
key: 'timeout',
spec: {
label: 'Timeout',
icon: Timer,
configured: (m) => m.timeout != null,
summarize: (m) => {
const t = m.timeout
if (t == null) return def('None')
if (typeof t === 'number') return cfg(formatDur(t))
if (t.type === 'static') {
const v = Number(t.value)
return Number.isFinite(v) ? cfg(formatDur(v)) : cfg('Dynamic')
}
return cfg('Dynamic')
}
}
},
{
key: 'concurrency',
spec: {
label: 'Concurrency limit',
icon: Gauge,
applies: (m) => m.value.type === 'rawscript' || m.value.type === 'script',
// Presence for the step's own limit (the user may be mid-edit, and `summary` says
// when it is a no-op), effectiveness for the referenced script's — a remote
// value nobody is typing into, where 0 just means the script has no limit.
configured: (m, ctx) =>
isWorkspaceScript(m)
? ctx.referenced?.concurrent_limit != undefined && ctx.referenced.concurrent_limit > 0
: hasInlineConcurrency(m),
summarize: (m, ctx) => {
if (isWorkspaceScript(m)) {
const l = ctx.referenced?.concurrent_limit
return l != undefined && l > 0 ? cfg(`Max ${l}`) : def('None')
}
const l = inlineConcurrentLimit(m)
if (l === undefined) return def('None')
if (!(l > 0)) return inv('Invalid limit')
const key = m.value.type === 'rawscript' ? m.value.custom_concurrency_key : undefined
return cfg(`Max ${l}${key ? ' per key' : ''}`)
}
}
},
{
key: 'priority',
spec: {
label: 'Priority',
icon: ChevronsUp,
configured: (m) => m.priority !== undefined,
summarize: (m) => {
if (m.priority === undefined) return def('Off')
// 0 is how the runtime spells "no priority".
return m.priority > 0 ? cfg('High priority') : inv('No priority set')
}
}
},
{
key: 'cache',
spec: {
label: 'Cache results',
icon: Database,
// The worker takes the module's cache_ttl over the referenced script's, so a
// module-level TTL must show here even for a workspace-script step.
configured: (m, ctx) =>
m.cache_ttl !== undefined || (isWorkspaceScript(m) && (ctx.referenced?.cache_ttl ?? 0) > 0),
summarize: (m, ctx) => {
const ttl = effectiveCacheTtl(m, ctx)
if (ttl === undefined) return def('Off')
return ttl > 0 ? cfg(formatDur(ttl)) : inv('No TTL set')
}
}
},
{
key: 'debounce',
spec: {
label: 'Debounce',
icon: Combine,
configured: (m) => m.debouncing?.debounce_delay_s !== undefined,
summarize: (m) => {
const d = m.debouncing?.debounce_delay_s
if (d === undefined) return def('Off')
return d > 0 ? cfg(`${formatDur(d)} debounce`) : inv('No delay set')
}
}
},
{
key: 'lifetime',
spec: {
label: 'Lifetime',
icon: Trash2,
configured: (m) => m.delete_after_secs != null,
summarize: (m) => {
const s = m.delete_after_secs
if (s == null) return def('Off')
return s === 0 ? cfg('Delete now') : cfg(`Delete after ${formatDur(s)}`)
}
}
}
]
/** The settings that apply to this step, in canonical order.
* `referenced` supplies the workspace script's own settings for `script` steps,
* whose concurrency and cache live on the script rather than on the step. */
export function describeStepSettings(
mod: FlowModule,
referenced?: ScriptAdvancedSettingsFields
): StepSettingView[] {
const ctx: Ctx = { referenced }
return SPECS.filter(({ spec }) => spec.applies?.(mod) ?? true).map(({ key, spec }) => ({
key,
label: spec.label,
tooltip: spec.tooltip?.(mod) ?? spec.label,
icon: spec.icon,
configured: spec.configured(mod, ctx),
summary: spec.summarize(mod, ctx)
}))
}
/** How a trigger step decides it has nothing to process. Stored on the step at
* creation, so changing it only affects newly created steps. */
// Falsy-or-empty, not `result == undefined`: a trigger returning nothing new may say so
// with any empty value, and stopping is always the right response.
const TRIGGER_STOP_EXPR = '!result || (Array.isArray(result) && result.length == 0)'
/** The config a setting is seeded with when it is switched on. Read by the setting
* editors and by every path that creates a step, so both agree. Settings absent from
* this map have no seeded config (the editor writes the value directly). */
const DEFAULTS = {
skip: () => ({ expr: 'false' }),
'early-stop': (kind?: 'trigger' | 'end') =>
kind === 'trigger'
? { expr: TRIGGER_STOP_EXPR, skip_if_stopped: true }
: kind === 'end'
? { expr: 'true', skip_if_stopped: false }
: {
expr: 'result == undefined',
skip_if_stopped: false,
error_message: undefined,
error_include_result: false
},
suspend: () => ({ required_events: 1, timeout: 1800 }),
sleep: () => ({ type: 'static' as const, value: 0 }),
cache: () => 600,
lifetime: () => 0,
priority: () => 100
} satisfies Partial<Record<StepSettingKey, (kind?: 'trigger' | 'end') => unknown>>
export type SeededSettingKey = keyof typeof DEFAULTS
/** Seeded config for a setting. Typed per key, so an unhandled key is a compile
* error rather than a silent `undefined`. */
export function stepSettingDefaults<K extends SeededSettingKey>(
key: K,
kind?: 'trigger' | 'end'
): ReturnType<(typeof DEFAULTS)[K]> {
return DEFAULTS[key](kind) as ReturnType<(typeof DEFAULTS)[K]>
}
@@ -169,7 +169,12 @@
</Button>
{#if !loading}
<Drawer bind:open={previewOpen} size="75%" {preventEscape} alwaysOpen={deferContent}>
<Drawer
bind:open={previewOpen}
size="75%"
{preventEscape}
alwaysOpen={deferContent}
>
<FlowPreviewContent
bind:this={flowPreviewContent}
open={previewOpen}
@@ -61,6 +61,13 @@
refreshFlowStateStore(flowStore)
}
function deleteFailureModule() {
flowStore.val.value.failure_module = undefined
// The panel has to land somewhere once the error handler is gone, but that is a
// consequence of the delete — not a request to see the flow's settings.
selectionManager.selectId('settings-metadata', { openPanel: false })
}
const smallFailureModule = $derived(!(failureModuleId && diffManager && moduleAction) && compact)
</script>
@@ -103,10 +110,7 @@
title="Delete failure script"
type="button"
class="ml-1"
onclick={() => {
flowStore.val.value.failure_module = undefined
selectionManager.selectId('settings-metadata')
}}
onclick={deleteFailureModule}
>
<X size={12} />
</button>
@@ -117,10 +121,7 @@
title="Delete failure script"
type="button"
class="absolute -top-1.5 -right-1.5 rounded-full bg-surface border border-border p-0.5 hover:bg-surface-hover"
onclick={() => {
flowStore.val.value.failure_module = undefined
selectionManager.selectId('settings-metadata')
}}
onclick={deleteFailureModule}
>
<X size={10} />
</button>
@@ -4,23 +4,8 @@
import Popover from '$lib/components/Popover.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { classNames, type Item, type StateStore } from '$lib/utils'
import {
Bed,
Database,
Gauge,
EllipsisVertical,
PhoneIncoming,
Repeat,
Square,
SkipForward,
Pin,
X,
Play,
Loader2,
TriangleAlert,
Timer,
Maximize2
} from 'lucide-svelte'
import { EllipsisVertical, Pin, X, Play, Loader2, TriangleAlert, Maximize2 } from 'lucide-svelte'
import type { StepSettingView } from '../flowStepSettings'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import { fade } from 'svelte/transition'
import type { FlowEditorContext } from '../types'
@@ -55,12 +40,8 @@
selected?: boolean
deletable?: boolean
moduleAction: ModuleActionInfo | undefined
retry?: boolean
cache?: boolean
earlyStop?: boolean
skip?: boolean
suspend?: boolean
sleep?: boolean
/** Configured settings to badge, from flowStepSettings. */
settings?: StepSettingView[]
mock?:
| {
enabled?: boolean
@@ -72,12 +53,8 @@
label: string
path?: string
nodeState?: FlowNodeState
concurrency?: boolean
// TODO: Implement for this one. See how concurrency is implemented.
debouncing?: boolean
retries?: number | undefined
warningMessage?: string | undefined
isTrigger?: boolean
editMode?: boolean
alwaysShowOutputPicker?: boolean
loopStatus?: { type: 'inside' | 'self'; flow: 'forloopflow' | 'whileloopflow' } | undefined
@@ -97,23 +74,15 @@
selected = false,
deletable = false,
moduleAction = undefined,
retry = false,
cache = false,
earlyStop = false,
skip = false,
suspend = false,
sleep = false,
settings = [],
mock = { enabled: false },
bold = false,
id = undefined,
label,
path = '',
nodeState,
concurrency = false,
debouncing = false,
retries = undefined,
warningMessage = undefined,
isTrigger = false,
editMode = false,
alwaysShowOutputPicker = false,
loopStatus = undefined,
@@ -304,112 +273,23 @@
class="absolute text-sm right-2 flex flex-row gap-1 z-10 transition-all duration-100"
style={`bottom: ${outputPickerBarOpen ? '-38px' : '-12px'}`}
>
{#if retry}
{#each settings as s (s.key)}
{@const Icon = s.icon}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
{#if retries}<span class="text-red-400 mr-2">{retries}</span>{/if}
<Repeat size={12} />
{#if s.key === 'retries' && retries}<span class="text-red-400 mr-2">{retries}</span
>{/if}
<Icon size={12} />
</div>
{#snippet text()}
Retries
{s.tooltip}
<span class={s.summary.mono ? 'font-mono' : ''}>· {s.summary.text}</span>
{/snippet}
</Popover>
{/if}
{#if concurrency}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
<Gauge size={12} />
</div>
{#snippet text()}
Concurrency Limits
{/snippet}
</Popover>
{/if}
{#if debouncing}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
<Timer size={12} />
</div>
{#snippet text()}
Debouncing
{/snippet}
</Popover>
{/if}
{#if cache}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center rounded border bg-surface border-gray-400 text-secondary px-1 py-0.5"
>
<Database size={12} />
</div>
{#snippet text()}
Cached
{/snippet}
</Popover>
{/if}
{#if earlyStop}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<Square size={12} />
</div>
{#snippet text()}
{isTrigger ? 'Stop early if there are no new events' : 'Early stop/break'}
{/snippet}
</Popover>
{/if}
{#if skip}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<SkipForward size={12} />
</div>
{#snippet text()}
Skip
{/snippet}
</Popover>
{/if}
{#if suspend}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<PhoneIncoming size={12} />
</div>
{#snippet text()}
Suspend
{/snippet}
</Popover>
{/if}
{#if sleep}
<Popover notClickable>
<div
transition:fade|local={{ duration: 200 }}
class="center-center bg-surface rounded border border-gray-400 text-secondary px-1 py-0.5"
>
<Bed size={12} />
</div>
{#snippet text()}
Sleep
{/snippet}
</Popover>
{/if}
{/each}
{#if mock?.enabled}
<Popover notClickable>
<button
@@ -469,6 +349,7 @@
{onUpdateMock}
{path}
{loopStatus}
logsToggle
rightMargin
historyOffset={{ mainAxis: 12, crossAxis: -9 }}
clazz="p-1"
@@ -3,6 +3,12 @@
import Badge from '$lib/components/common/badge/Badge.svelte'
import type { FlowNodeColorClasses } from '$lib/components/graph'
import { twMerge } from 'tailwind-merge'
import { getContext } from 'svelte'
// Detached mode (sessions): a step's editor is hidden until asked for, so add the
// hint to the name tooltip. Provided by FlowEditor; absent (=> false) elsewhere.
const stepExploreHint = getContext<(() => boolean) | undefined>('flowGraphStepExploreHint')
const showExploreHint = $derived(stepExploreHint?.() ?? false)
let iconWidth: number = $state(0)
let idBadgeWidth: number | undefined = $state(undefined)
@@ -55,6 +61,9 @@
<div>
<div>{label}</div>
{#if path != ''}<div>{path}</div>{/if}
{#if showExploreHint}
<div class="mt-1 text-tertiary">double click, or click again, to explore</div>
{/if}
</div>
{/snippet}
</Popover>
@@ -8,7 +8,6 @@
createBranches,
createLoop,
createWhileLoop,
deleteFlowStateById,
emptyModule,
pickScript,
pickFlow,
@@ -17,6 +16,7 @@
} from '$lib/components/flows/flowStateUtils.svelte'
import type { FlowModule, Job, ScriptLang } from '$lib/gen'
import { emptyFlowModuleState } from '../utils.svelte'
import { stepSettingDefaults } from '../flowStepSettings'
import { dfs } from '../dfs'
import { nextId, copyId } from '../flowModuleNextId'
@@ -35,6 +35,7 @@
import type { PropPickerContext } from '$lib/components/prop_picker'
import { JobService } from '$lib/gen'
import { findModuleInFlow } from '../flowTree'
import { addBranch as addBranchOp, removeBranch as removeBranchOp } from '../branchOps'
import type { InlineScript, InsertKind } from '$lib/components/graph/graphBuilder.svelte'
import { MoveManager } from '$lib/components/graph/moveManager.svelte'
import { refreshFlowStateStore } from '../flowStoreRefresh.svelte'
@@ -203,15 +204,12 @@
flowStateStore.val[module.id] = state
if (kind == 'approval') {
module.suspend = { required_events: 1, timeout: 1800 }
module.suspend = stepSettingDefaults('suspend')
} else if (kind == 'trigger') {
module.stop_after_if = {
expr: '!result || (Array.isArray(result) && result.length == 0)',
skip_if_stopped: true
}
module.stop_after_if = stepSettingDefaults('early-stop', 'trigger')
} else if (kind == 'end') {
module.summary = 'Terminate flow'
module.stop_after_if = { skip_if_stopped: false, expr: 'true' }
module.stop_after_if = stepSettingDefaults('early-stop', 'end')
}
return module
@@ -272,40 +270,11 @@
}
export async function addBranch(id: string) {
push(history, flowStore.val)
let module = findModuleById(id)
if (!module) {
throw new Error(`Node ${id} not found`)
}
if (module.value.type === 'branchone' || module.value.type === 'branchall') {
module.value.branches.splice(module.value.branches.length, 0, {
summary: '',
expr: 'false',
modules: []
})
}
addBranchOp(id, { flowStore, history })
}
export function removeBranch(id: string, index: number) {
push(history, flowStore.val)
let module = findModuleById(id)
if (!module) {
throw new Error(`Node ${id} not found`)
}
if (module.value.type === 'branchone' || module.value.type === 'branchall') {
const offset = module.value.type === 'branchone' ? 1 : 0
if (module.value.branches[index - offset]?.modules) {
const leaves = dfs(module.value.branches[index - offset].modules, (mod) => mod.id)
leaves.forEach((leafId: string) => deleteFlowStateById(leafId, flowStateStore))
}
module.value.branches.splice(index - offset, 1)
}
removeBranchOp(id, index, { flowStore, flowStateStore, history })
}
// A single delete can have several consequences at once (emptied groups *and*
@@ -768,7 +737,9 @@
toolKind
)
const id = tools[tools.length - 1].id
selectionManager.selectId(id)
// Reveal the new tool's config right away — in modal (unanchored)
// panel mode its editor is otherwise hidden behind the graph.
selectionManager.selectId(id, { openPanel: true })
}
refreshFlowStateStore(flowStore)
dispatch('change')
@@ -827,7 +798,9 @@
{ extraModules, displayState: groupDisplayState }
)
selectionManager.selectId(module.id)
// Inserting is a deliberate "now edit this": in modal mode the new step's
// editor is otherwise hidden behind the graph.
selectionManager.selectId(module.id, { openPanel: true })
if (detail.inlineScript?.instructions) {
dispatch('generateStep', {
@@ -954,7 +927,7 @@
targetModules.splice(targetIndex + 1, 0, clone)
refreshFlowStateStore(flowStore)
selectionManager.selectId(clone.id)
selectionManager.selectId(clone.id, { openPanel: true })
}}
onUpdateMock={(detail) => {
let module = findModuleById(detail.id)
@@ -19,6 +19,9 @@
kind?: 'script' | 'trigger' | 'preprocessor' | 'failure'
allowTrigger?: boolean
toolMode?: boolean
/** Narrow layout (450px instead of 650px). Defaults on for the preprocessor
* and failure pickers; set it when the container cannot fit the wide one. */
small?: boolean
}
let {
@@ -27,7 +30,8 @@
disableAi = false,
kind = 'script',
allowTrigger = true,
toolMode = false
toolMode = false,
small: smallProp = undefined
}: Props = $props()
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
@@ -41,7 +45,7 @@
| 'aisandbox' = $state(untrack(() => kind))
let preFilter: 'all' | 'workspace' | 'hub' = $state('all')
let loading = $state(false)
let small = $derived(kind === 'preprocessor' || kind === 'failure')
let small = $derived(smallProp ?? (kind === 'preprocessor' || kind === 'failure'))
let height = $state(0)
let owners = $state([])
@@ -53,9 +57,7 @@
<div
id="flow-editor-insert-module"
class="flex flex-col h-full {small ? 'w-[450px]' : 'w-[650px]'} gap-2 {small
? 'min-w-[450px]'
: 'min-w-[650px]'}"
class="flex flex-col h-full gap-2 max-w-full {small ? 'w-[450px]' : 'w-[650px]'}"
onwheel={(e) => {
e.stopPropagation()
}}
@@ -7,10 +7,8 @@
import { prettyLanguage } from '$lib/common'
import { msToSec, type Item } from '$lib/utils'
import FlowJobsMenu from './FlowJobsMenu.svelte'
import {
isTriggerStep,
type OnSelectedIteration
} from '$lib/components/graph/graphBuilder.svelte'
import { type OnSelectedIteration } from '$lib/components/graph/graphBuilder.svelte'
import { describeStepSettings } from '$lib/components/flows/flowStepSettings'
import { checkIfParentLoop } from '$lib/components/flows/utils.svelte'
import type { FlowEditorContext } from '$lib/components/flows/types'
import { twMerge } from 'tailwind-merge'
@@ -86,14 +84,8 @@
let itemProps = $derived({
selected: selectionManager && selectionManager.isNodeSelected(mod.id),
retry: mod.retry?.constant != undefined || mod.retry?.exponential != undefined,
earlyStop: mod.stop_after_if != undefined || mod.stop_after_all_iters_if != undefined,
skip: Boolean(mod.skip_if),
suspend: Boolean(mod.suspend),
sleep: Boolean(mod.sleep),
cache: Boolean(mod.cache_ttl),
mock: mod.mock,
concurrency: Boolean(mod?.value?.['concurrent_limit'])
settings: describeStepSettings(mod).filter((s) => s.configured)
})
let parentLoop = $derived(
@@ -212,6 +204,11 @@
on:delete
on:move
on:pointerdown={handlePointerDown}
onUpdateMock={(mock) => {
mod.mock = mock
onUpdateMock?.({ id: mod.id, mock })
}}
alwaysShowOutputPicker
{...itemProps}
id={mod.id}
label={mod.summary || 'Run one branch'}
@@ -232,6 +229,11 @@
on:delete
on:move
on:pointerdown={handlePointerDown}
onUpdateMock={(mock) => {
mod.mock = mock
onUpdateMock?.({ id: mod.id, mock })
}}
alwaysShowOutputPicker
id={mod.id}
{...itemProps}
label={mod.summary || `Run all branches${mod.value.parallel ? ' (parallel)' : ''}`}
@@ -253,8 +255,6 @@
on:delete
on:move
onUpdateMock={(mock) => {
console.log('onUpdateMock', mock)
mod.mock = mock
onUpdateMock?.({ id: mod.id, mock })
}}
@@ -274,7 +274,6 @@
? `Inline ${prettyLanguage(mod.value.language)}`
: 'To be defined')}
path={`path` in mod.value ? mod.value.path : ''}
isTrigger={isTriggerStep(mod)}
alwaysShowOutputPicker={!mod.id.startsWith('subflow:') && mod.id !== 'preprocessor'}
loopStatus={parentLoop ? { type: 'inside', flow: parentLoop.type } : undefined}
inputTransform={mod.value.type !== 'identity' ? mod.value.input_transforms : undefined}
@@ -165,6 +165,7 @@
clazz="p-1"
{onEditInput}
selectionId={id ?? label ?? ''}
logsToggle={nodeKind === 'result'}
testJob={job}
disableMock
disableHistory
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest'
import type { FlowModule } from '$lib/gen'
import { moduleSlot, savedModuleById } from './moduleSlot'
function mod(id: string, content = ''): FlowModule {
return {
id,
value: { type: 'rawscript', language: 'bun', content, input_transforms: {} }
} as FlowModule
}
describe('moduleSlot', () => {
it('follows its step when a preceding sibling is deleted', () => {
const modules = [mod('a'), mod('b'), mod('c')]
const slot = moduleSlot(() => modules, 'b', modules[1])
modules.splice(0, 1)
expect(slot.get().id).toBe('b')
})
it('drops a write once its own step is gone, instead of hitting the step that took its place', () => {
// This is the whole point: an editor flushes its trailing keystrokes on unmount, and
// the delete has already spliced the array by then.
const b = mod('b')
const modules = [mod('a'), b, mod('c')]
const slot = moduleSlot(() => modules, 'b', b)
modules.splice(1, 1)
const target = slot.get()
;(target.value as { content: string }).content = 'typed into b'
expect(target).toBe(b)
expect(modules.map((m) => m.id)).toEqual(['a', 'c'])
expect((modules[1].value as { content: string }).content).toBe('')
})
it('ignores a whole-module replacement aimed at a deleted step', () => {
const b = mod('b')
const modules = [mod('a'), b]
const slot = moduleSlot(() => modules, 'b', b)
modules.splice(1, 1)
slot.set(mod('replacement'))
expect(modules.map((m) => m.id)).toEqual(['a'])
})
})
describe('savedModuleById', () => {
function branchOne(id: string, branches: FlowModule[][]): FlowModule {
return {
id,
value: {
type: 'branchone',
default: [],
branches: branches.map((modules) => ({ expr: 'true', modules }))
}
} as unknown as FlowModule
}
it('finds a step whose branch has moved, where its old position now holds another step', () => {
// The deployed flow keeps the order it was deployed in. Matching by position would
// pair the step with the other branch's step and diff against the wrong code.
const deployed = branchOne('a', [[mod('x', 'code-x')], [mod('y', 'code-y')]])
const found = savedModuleById(deployed, 'y')
expect((found?.value as { content: string }).content).toBe('code-y')
})
it('reaches into a loop nested in a branch', () => {
const loop = {
id: 'l',
value: { type: 'forloopflow', modules: [mod('deep', 'code-deep')] }
} as unknown as FlowModule
const deployed = branchOne('a', [[loop]])
expect((savedModuleById(deployed, 'deep')?.value as { content: string }).content).toBe(
'code-deep'
)
})
it('returns undefined for a step that has no deployed counterpart yet', () => {
expect(savedModuleById(branchOne('a', [[mod('x')]]), 'brand-new')).toBeUndefined()
})
it('searches the deployed top-level module list when given one', () => {
// Inserting a step at the top shifts every later one, so position and id disagree.
const deployed = [mod('a', 'code-a'), mod('b', 'code-b')]
expect((savedModuleById(deployed, 'b')?.value as { content: string }).content).toBe('code-b')
})
})
@@ -0,0 +1,62 @@
import type { FlowModule } from '$lib/gen'
/**
* A read/write slot for one step, anchored by id rather than by array position. Editors
* write through their binding as they unmount, and a delete has already spliced the array
* by then by index that write would hit whichever step took the deleted one's place.
*
* Svelte extracts the getter once per block instance, so the anchor cannot drift; the
* hosting `{#each}` must therefore be keyed by id, or a reused block keeps a dead anchor.
*/
export function moduleSlot(
getModules: () => FlowModule[],
id: string | undefined,
detached: FlowModule
) {
return {
get: (): FlowModule => getModules().find((m) => m.id === id) ?? detached,
set: (v: FlowModule) => {
const modules = getModules()
const i = modules.findIndex((m) => m.id === id)
if (i !== -1) {
modules[i] = v
}
}
}
}
/**
* The deployed counterpart of a step, matched by id rather than by position. Takes either
* one deployed step to search under, or the deployed flow's top-level module list.
*
* The saved flow keeps the order it was deployed in, so any structural edit since
* reordering branches, inserting or deleting a step shifts positions apart. Matching by
* index would then pair a step with a different step's deployed code, which surfaces as
* the wrong "last deployed" in the diff.
*/
export function savedModuleById(
saved: FlowModule | FlowModule[] | undefined,
id: string | undefined
): FlowModule | undefined {
if (!saved || !id) return undefined
let found: FlowModule | undefined = undefined
const walk = (modules: FlowModule[]) => {
for (const m of modules) {
if (found) return
if (m.id === id) {
found = m
return
}
const v = m.value
if (v.type === 'forloopflow' || v.type === 'whileloopflow') walk(v.modules)
else if (v.type === 'branchone') {
walk(v.default)
for (const b of v.branches) walk(b.modules)
} else if (v.type === 'branchall') {
for (const b of v.branches) walk(b.modules)
}
}
}
walk(Array.isArray(saved) ? saved : [saved])
return found
}
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { resolvePanelMode, MODAL_PANEL_BREAKPOINT } from './panelPlacement'
const auto = { enabled: true, preference: 'auto' as const }
describe('resolvePanelMode', () => {
it('detaches on auto once the editor is narrower than the breakpoint', () => {
expect(resolvePanelMode({ ...auto, width: MODAL_PANEL_BREAKPOINT - 1 })).toBe('modal')
expect(resolvePanelMode({ ...auto, width: MODAL_PANEL_BREAKPOINT })).toBe('docked')
})
it('stays docked at zero width, which means unlaid-out rather than narrow', () => {
expect(resolvePanelMode({ ...auto, width: 0 })).toBe('docked')
})
it('holds a pinned preference against the width that contradicts it', () => {
const wide = MODAL_PANEL_BREAKPOINT + 400
expect(resolvePanelMode({ enabled: true, preference: 'modal', width: wide })).toBe('modal')
expect(resolvePanelMode({ enabled: true, preference: 'docked', width: 400 })).toBe('docked')
})
it('keeps whitelabel embeds docked whatever was picked', () => {
expect(resolvePanelMode({ enabled: false, preference: 'modal', width: 400 })).toBe('docked')
})
})
@@ -0,0 +1,29 @@
/** Below this editor width the step-details pane doesn't fit alongside the graph. */
export const MODAL_PANEL_BREAKPOINT = 1280
/**
* What the user asked for, which is not the same as where the panel ends up: `auto`
* follows the editor's width, the other two pin it regardless of width.
*/
export type FlowPanelPreference = 'auto' | 'docked' | 'modal'
/** Where the panel actually is. */
export type FlowPanelMode = 'docked' | 'modal'
/**
* Resolves where the step panel goes. Split from the controller that holds the state so
* the rule can be tested without a component: the zero-width case below is the one a
* refactor gets wrong silently, since an unlaid-out editor and a narrow one both measure
* small and only one of them should detach.
*/
export function resolvePanelMode(input: {
enabled: boolean
preference: FlowPanelPreference
width: number
}): FlowPanelMode {
if (!input.enabled) return 'docked'
if (input.preference !== 'auto') return input.preference
// A zero width means the editor has not been laid out yet. That is not evidence of a
// narrow screen, so stay docked rather than flashing through a modal.
return input.width > 0 && input.width < MODAL_PANEL_BREAKPOINT ? 'modal' : 'docked'
}
@@ -47,7 +47,7 @@
'AI Agent': { icon: BotIcon, iconClass: 'text-ai' },
'AI Sandbox': { icon: BotIcon, showChevron: true, iconClass: 'text-ai' },
'Claude Code': { icon: BotIcon, iconClass: 'text-ai' },
MCP: { icon: Plug, showChevron: true },
MCP: { icon: Plug },
'Web Search': { icon: Globe }
}
@@ -131,7 +131,7 @@
<button
class="p-4 gap-1 flex flex-row grow hover:bg-surface-hover bg-surface transition-all text-primary"
onclick={() => {
dispatch('pick', { path, hash: lockHash ? hash : undefined })
dispatch('pick', { path, hash: lockHash ? hash : undefined, summary })
}}
>
<div class="flex flex-col">
@@ -110,9 +110,14 @@
e.preventDefault()
let item = filteredWithOwner[selected]
if (kind == 'flow') {
dispatch('pickFlow', { path: item.path })
dispatch('pickFlow', { path: item.path, summary: item.summary })
} else {
dispatch('pickScript', { path: item.path, hash: lockHash ? item.hash : undefined, kind })
dispatch('pickScript', {
path: item.path,
hash: lockHash ? item.hash : undefined,
summary: item.summary,
kind
})
}
}
}
@@ -192,9 +197,9 @@
onmousemove={() => onHover?.(index)}
onClick={() => {
if (kind == 'flow') {
dispatch('pickFlow', { path: path })
dispatch('pickFlow', { path, summary })
} else {
dispatch('pickScript', { path: path, hash: lockHash ? hash : undefined, kind })
dispatch('pickScript', { path, hash: lockHash ? hash : undefined, summary, kind })
}
}}
startIcon={{
@@ -0,0 +1,105 @@
<script lang="ts">
import { getContext, setContext } from 'svelte'
import { writable } from 'svelte/store'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import PropPicker from '$lib/components/propertyPicker/PropPicker.svelte'
import FlowPlugConnect from '$lib/components/FlowPlugConnect.svelte'
import type { PropPickerContext } from '$lib/components/prop_picker'
import type { PickableProperties } from '../previousResults'
import type { PropPickerWrapperContext } from './PropPickerWrapper.svelte'
import { useConnect } from './useConnect.svelte'
import { twMerge } from 'tailwind-merge'
interface Props {
/** Identifies this input within the panel; arming another one disarms it. */
id: string
pickableProperties: PickableProperties | undefined
result?: any
extraResults?: any
/** Receives the picked property, whether it came from the popover or the graph. */
onSelect: (path: string) => void
disabled?: boolean
}
let { id, pickableProperties, result, extraResults, onSelect, disabled = false }: Props = $props()
const propPickerContext = getContext<PropPickerContext | undefined>('PropPickerContext')
const inModalPanel = $derived(propPickerContext?.inModalPanel?.() ?? false)
const connect = useConnect({
inModalPanel: () => inModalPanel,
hasPickableProperties: () => pickableProperties != undefined,
flowPropPickerConfig: propPickerContext?.flowPropPickerConfig ?? writable(undefined)
})
// PropPicker reads these to filter and highlight against what is being typed. Only the
// step input form produces that signal, so here they stay empty.
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
propPickerConfig: writable(undefined),
inputMatches: writable(undefined),
exprBeingEdited: writable([]),
connectProp: () => {},
clearConnect: () => connect.disarm(),
pickerMode: () => 'popover' as const,
pickableProperties: () => undefined,
result: () => undefined,
extraResults: () => undefined,
onPick: () => {}
})
let open = $state(false)
// A pick from the graph resolves without touching the popover, so follow the armed
// slot rather than tracking open/closed separately.
$effect(() => {
if (!connect.isArmed(id)) {
open = false
}
})
</script>
<!-- excludeSelectors: a step's output picker in the graph is the other half of connecting, not
somewhere else to click. Reaching for it must not close this popover, which would disarm
the connect and leave the pick that follows with nowhere to land. -->
<Popover
bind:isOpen={open}
class="flex"
placement="bottom-start"
closeOnOutsideClick
excludeSelectors="[data-prop-picker]"
contentClasses="rounded-md border bg-surface shadow-lg overflow-hidden"
on:openChange={({ detail }) => (detail ? connect.arm({ id, onSelect }) : connect.disarm())}
>
{#snippet trigger()}
<FlowPlugConnect
connecting={connect.isArmed(id)}
{disabled}
title="Connect a property from a previous step"
wrapperClasses={twMerge(
// Revealed by the row it sits in, like the pane-mode plug. Armed or open, it stays
// put: the pointer has to be able to leave the row without the control vanishing.
'group-hover:opacity-100 transition-opacity',
connect.isArmed(id) || open ? '' : 'opacity-0'
)}
/>
{/snippet}
{#snippet content()}
<div class="max-h-80 w-72 overflow-auto p-2">
{#if pickableProperties}
<PropPicker
{pickableProperties}
previousId={pickableProperties?.previousId}
{result}
{extraResults}
on:select={({ detail }) => {
connect.resolve(detail)
open = false
}}
/>
{:else}
<div class="p-2 text-xs text-tertiary">Nothing to pick from yet.</div>
{/if}
</div>
{/snippet}
</Popover>
@@ -67,6 +67,9 @@
initial?: boolean
onResetInitial?: () => void
customEmptyJobMessage?: string
/** Offer a result/logs toggle. Only for viewers that show the result alone —
* where a log pane sits alongside it, the toggle just hides what is already there. */
logsToggle?: boolean
}
let {
@@ -96,7 +99,8 @@
onEditInput,
selectionId,
initial,
customEmptyJobMessage
customEmptyJobMessage,
logsToggle = false
}: Props = $props()
let jsonView = $state(false)
@@ -523,7 +527,7 @@
{/if}
<!-- Logs button -->
{#if selectedJob?.type === 'CompletedJob' && selectedJob?.['logs']}
{#if logsToggle && selectedJob?.type === 'CompletedJob' && selectedJob?.['logs']}
<Tooltip>
<Button
size="xs2"
@@ -624,7 +628,7 @@
hoveringResult = false
}}
>
{#if showLogs && selectedJob?.type === 'CompletedJob' && selectedJob?.['logs']}
{#if logsToggle && showLogs && selectedJob?.type === 'CompletedJob' && selectedJob?.['logs']}
<LogViewer
small
jobId={selectedJob.id}
@@ -13,6 +13,21 @@
inputMatches: Writable<{ word: string; value: string }[] | undefined>
connectProp: (propName: string, onSelect: SelectCallback) => void
clearConnect: () => void
/** 'popover' hangs the picker off each input's own connect button instead of
* taking a pane — for single-argument settings rows, which are not the step's
* input form. */
pickerMode: () => 'pane' | 'popover'
/** The wrapper owns these; nested inputs receive none of their own. */
pickableProperties: () => PickableProperties | undefined
/** The step's own result, and anything extra worth offering beside it (a loop's
* `all_iters`). Only the pane renders them directly — in popover mode the picker
* hangs off each input, so it reads them from here instead. */
result: () => any
extraResults: () => any
/** Deliver a pick the way the pane does — as a `select` event, so each setting's own
* handler inserts it at the cursor. Replacing the whole value is right for a step
* input but destroys a half-written predicate. */
onPick: (path: string) => void
exprBeingEdited: Writable<string[]>
}
</script>
@@ -27,7 +42,7 @@
import type { PickableProperties } from '../previousResults'
import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte'
import type { PropPickerContext } from '$lib/components/prop_picker'
import { useConnect } from './useConnect.svelte'
interface Props {
pickableProperties: PickableProperties | undefined
@@ -39,7 +54,9 @@
notSelectable?: boolean
noPadding?: boolean
paneClass?: string
noFlowPlugConnect?: boolean
/** Settings rows reuse the step-input form for one argument but are not the input
* form; their picker belongs in a popover. */
popover?: boolean
children?: import('svelte').Snippet
}
@@ -53,7 +70,7 @@
notSelectable = false,
noPadding = false,
paneClass = '',
noFlowPlugConnect = false,
popover = false,
children
}: Props = $props()
@@ -64,38 +81,31 @@
const inputMatches = writable<{ word: string; value: string }[] | undefined>(undefined)
const dispatch = createEventDispatcher()
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
const propPickerContext = getContext<PropPickerContext>('PropPickerContext')
const { flowPropPickerConfig } = propPickerContext
flowPropPickerConfig.set(undefined)
// The modal panel covers the graph, so a connect armed there could never be resolved by
// clicking a step — this pane is the only picker in that mode. See `graphParticipates`.
const inModalPanel = $derived(propPickerContext.inModalPanel?.() ?? false)
const connect = useConnect({
inModalPanel: () => inModalPanel,
hasPickableProperties: () => pickableProperties != undefined,
flowPropPickerConfig,
localConfig: propPickerConfig
})
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
propPickerConfig,
inputMatches,
connectProp: (propName, onSelect) => {
const config = {
propName,
onSelect,
clearFocus: () => {
propPickerConfig.set(undefined)
flowPropPickerConfig.set(undefined)
}
}
propPickerConfig.set(config)
if (!noFlowPlugConnect) {
flowPropPickerConfig.set({
...config,
clearFocus: () => {
propPickerConfig.set(undefined)
flowPropPickerConfig.set(undefined)
}
})
}
},
clearConnect: () => {
flowPropPickerConfig.set(undefined)
propPickerConfig.set(undefined)
},
connectProp: (propName, onSelect) => connect.arm({ id: propName, onSelect }),
clearConnect: connect.disarm,
pickerMode: () => (popover ? 'popover' : 'pane'),
pickableProperties: () => pickableProperties,
result: () => result,
extraResults: () => extraResults,
onPick: (path) => dispatch('select', path),
exprBeingEdited: writable<string[]>([])
})
@@ -108,67 +118,72 @@
let rightPaneHeight: number = $state(0)
</script>
{#snippet pickerBody()}
<div bind:clientHeight={rightPaneHeight} class="min-h-40 h-full !bg-surface">
<AnimatedButton
animate={$propPickerConfig != undefined}
baseRadius="4px"
wrapperClasses="prop-picker-inputs h-full w-full pt-1"
marginWidth="3px"
ringColor="transparent"
animationDuration="4s"
>
{#if result != undefined && !pickableProperties}
<PropPickerResult
{result}
{extraResults}
{flow_input}
allowCopy={!notSelectable && !$propPickerConfig}
on:select={({ detail }) => {
dispatch('select', detail)
connect.resolve(detail)
}}
/>
{:else if pickableProperties}
<PropPicker
{result}
{extraResults}
{displayContext}
{error}
previousId={pickableProperties?.previousId}
{pickableProperties}
allowCopy={!notSelectable && !$propPickerConfig}
on:select={({ detail }) => {
dispatch('select', detail)
connect.resolve(detail)
}}
/>
{/if}
</AnimatedButton>
</div>
{/snippet}
<div
class="h-full w-full"
data-prop-picker-root
use:clickOutside={{
capture: true,
exclude: getPropPickerElements,
onClickOutside: () => {
propPickerConfig.set(undefined)
flowPropPickerConfig.set(undefined)
}
// Through the controller, not the stores: it owns the armed target, and a
// target left armed here would make the next click on that same input
// read as a toggle-off.
onClickOutside: connect.disarm
}}
>
<Splitpanes class={$propPickerConfig ? 'splitpanes-remove-splitter' : ''}>
<Pane minSize={20} size={60} class={'relative !transition-none'}>
<div style="height: {rightPaneHeight}px;" class={noPadding ? '' : 'p-2'}>
{@render children?.()}
</div>
</Pane>
<Pane minSize={20} size={40} class="!transition-none z-1000 relative {paneClass}">
<div bind:clientHeight={rightPaneHeight} class="min-h-40 h-full !bg-surface-secondary">
<AnimatedButton
animate={$propPickerConfig != undefined}
baseRadius="4px"
wrapperClasses="prop-picker-inputs h-full w-full pt-1"
marginWidth="3px"
ringColor="transparent"
animationDuration="4s"
>
{#if result != undefined && !pickableProperties}
<PropPickerResult
{result}
{extraResults}
{flow_input}
allowCopy={!notSelectable && !$propPickerConfig}
on:select={({ detail }) => {
dispatch('select', detail)
if ($propPickerConfig?.onSelect(detail)) {
$propPickerConfig?.clearFocus()
}
}}
/>
{:else if pickableProperties}
<PropPicker
{result}
{extraResults}
{displayContext}
{error}
previousId={pickableProperties?.previousId}
{pickableProperties}
allowCopy={!notSelectable && !$propPickerConfig}
on:select={({ detail }) => {
// console.log('selecting', detail)
dispatch('select', detail)
if ($propPickerConfig?.onSelect(detail)) {
$propPickerConfig?.clearFocus()
}
}}
/>
{/if}
</AnimatedButton>
</div>
</Pane>
</Splitpanes>
{#if popover}
<!-- The picker lives on each input's connect button here, so the row keeps its
full width — and its own spacing, since the setting around it lays that out. -->
{@render children?.()}
{:else}
<Splitpanes class={$propPickerConfig ? 'splitpanes-remove-splitter' : ''}>
<Pane minSize={20} size={60} class={'relative !transition-none'}>
<div style="height: {rightPaneHeight}px;" class={noPadding ? '' : 'p-2'}>
{@render children?.()}
</div>
</Pane>
<Pane minSize={20} size={40} class="!transition-none z-1000 relative {paneClass}">
{@render pickerBody()}
</Pane>
</Splitpanes>
{/if}
</div>
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import { graphParticipates, nextArmed, type ArmedTarget } from './connectPolicy'
const target = (id: string): ArmedTarget => ({ id, onSelect: () => {} })
describe('nextArmed', () => {
it('replaces the previous target so a pick has one destination', () => {
expect(nextArmed(target('iterator'), target('region'))?.id).toBe('region')
})
it('disarms when the already-armed input is clicked again', () => {
expect(nextArmed(target('region'), target('region'))).toBeUndefined()
})
})
describe('graphParticipates', () => {
const docked = { inModalPanel: false, hasPickableProperties: true }
it('offers the graph while a target is armed in a docked panel', () => {
expect(graphParticipates(target('region'), docked)).toBe(true)
})
it('never offers the graph from a modal panel, which covers it', () => {
expect(graphParticipates(target('region'), { ...docked, inModalPanel: true })).toBe(false)
})
it('stays out of it when the input has nothing to pick from', () => {
// Sleep and suspend show previous results but expose no pickable properties.
expect(graphParticipates(target('sleep'), { ...docked, hasPickableProperties: false })).toBe(
false
)
})
it('is off whenever nothing is armed', () => {
expect(graphParticipates(undefined, docked)).toBe(false)
})
})
@@ -0,0 +1,29 @@
/** An input waiting to receive the next picked property. */
export type ArmedTarget = {
/** Distinguishes targets within a panel — an argument name, or the input's own id. */
id: string
onSelect: (path: string) => void
}
/**
* Arming is exclusive: a panel has at most one target, so a pick from the picker or from
* a step node always has exactly one destination. Clicking the armed input again disarms.
*/
export function nextArmed(
current: ArmedTarget | undefined,
clicked: ArmedTarget
): ArmedTarget | undefined {
return current?.id === clicked.id ? undefined : clicked
}
/**
* Whether the graph offers its step outputs while this target is armed. The modal panel
* covers the graph, so connecting there could never be completed; with nothing pickable
* there is nothing for a node click to contribute.
*/
export function graphParticipates(
armed: ArmedTarget | undefined,
opts: { inModalPanel: boolean; hasPickableProperties: boolean }
): boolean {
return armed != undefined && !opts.inModalPanel && opts.hasPickableProperties
}
@@ -0,0 +1,84 @@
import type { Writable } from 'svelte/store'
import type { FlowPropPickerConfig } from '$lib/components/prop_picker'
import { graphParticipates, nextArmed, type ArmedTarget } from './connectPolicy'
/**
* Holds the panel's single armed connect target and keeps the graph in step with it.
*
* The graph is driven imperatively from `arm`/`disarm` rather than from an `$effect`:
* an effect that both reads the armed state and writes the shared store is the shape that
* self-invalidates.
*/
export function useConnect(opts: {
inModalPanel: () => boolean
hasPickableProperties: () => boolean
flowPropPickerConfig: Writable<FlowPropPickerConfig | undefined>
/** Mirrors the armed target for an in-panel picker, which filters by its prop name. */
localConfig?: Writable<
{ propName?: string; onSelect: (path: string) => boolean; clearFocus: () => void } | undefined
>
}) {
let armed = $state<ArmedTarget | undefined>(undefined)
function sync() {
opts.localConfig?.set(
armed
? {
propName: armed.id,
onSelect: (path: string) => {
resolve(path)
return true
},
clearFocus: disarm
}
: undefined
)
const participates = graphParticipates(armed, {
inModalPanel: opts.inModalPanel(),
hasPickableProperties: opts.hasPickableProperties()
})
opts.flowPropPickerConfig.set(
participates
? {
onSelect: (path: string) => {
resolve(path)
return true
},
clearFocus: disarm
}
: undefined
)
}
function arm(target: ArmedTarget) {
armed = nextArmed(armed, target)
sync()
}
function disarm() {
// `flowPropPickerConfig` is shared by every connect surface in the panel, so a component
// that holds nothing must not clear it — otherwise closing any picker cancels whichever
// input is actually armed, and the pick that follows has nowhere to land.
if (!armed) return
armed = undefined
opts.localConfig?.set(undefined)
opts.flowPropPickerConfig.set(undefined)
}
/** Deliver a picked property. Disarms first, so a target can never be written twice. */
function resolve(path: string) {
const target = armed
disarm()
target?.onSelect(path)
}
return {
get armed() {
return armed
},
isArmed: (id: string) => armed?.id === id,
arm,
disarm,
resolve
}
}
@@ -17,6 +17,7 @@ import type { ModulesTestStates } from '../modulesTest.svelte'
import type { ButtonProp } from '$lib/components/diffEditorTypes'
import type { SelectionManager } from '../graph/selectionUtils.svelte'
import type { FlowPanelPreference } from './panelPlacement'
import type { InferAssetsSqlQueryDetails } from '$lib/infer'
export type FlowInput = Record<
@@ -135,3 +136,21 @@ export type OutputViewerJob =
}
) & { result_stream?: string; result?: unknown })
| undefined
/** Set by FlowEditor so a panel's card header can host the panel's own chrome inline.
* A mounted host calls `claim` (keeping the returned unregister for teardown), which
* hides FlowEditor's fallback strip. */
export type FlowPanelDetachContext = {
claim: () => () => void
/** True while the detached modal is open. Its chrome lives in the panel's card header,
* so the modal draws no header of its own. */
modalOpen: () => boolean
close: () => void
/** False for whitelabel embeds that keep the classic always-docked pane they get no
* panel-position control at all, in the graph or anywhere else. */
enabled: () => boolean
/** The Auto/Attached/Detached choice, picked from the graph's control bar. Distinct
* from where the panel currently is: `auto` resolves against the editor's width. */
preference: () => FlowPanelPreference
setPreference: (preference: FlowPanelPreference) => void
}
@@ -1,9 +1,13 @@
<script lang="ts">
import GraphZoomControls from './GraphZoomControls.svelte'
import FlowPanelPlacementPicker from '$lib/components/flows/common/FlowPanelPlacementPicker.svelte'
import { overlayStack } from '$lib/components/common/overlayHost.svelte'
import { FlowService, type FlowModule, type FlowNote, type Job, type OpenFlow } from '../../gen'
import { findStepPath, parseExpandedSubflowId } from '$lib/components/restartFromStepPath'
import { expandedSubflowParentId } from '../flows/expandedSubflowStep'
import { sendUserToast } from '$lib/utils'
import { AI_OR_ASSET_NODE_TYPES, NODE, type GraphModuleState } from '.'
import { isTriggerStep } from '$lib/components/flows/flowStepSettings'
import { getContext, onDestroy, onMount, tick, untrack, type Snippet } from 'svelte'
import { createFlowDiffManager } from '../flows/flowDiffManager.svelte'
@@ -22,7 +26,6 @@
} from '@xyflow/svelte'
import {
graphBuilder,
isTriggerStep,
type InlineScript,
type InsertKind,
type NodeLayout,
@@ -88,7 +91,7 @@
import { getAllModules } from '../flows/flowExplorer'
import SelectionTool from './SelectionTool.svelte'
import PaneContextMenu from './PaneContextMenu.svelte'
import { SelectionManager } from './selectionUtils.svelte'
import { SelectionManager, isFlowLevelPanelTarget } from './selectionUtils.svelte'
import { ChangeTracker } from '$lib/svelte5Utils.svelte'
import { NoteManager } from './noteManager.svelte'
import type { MoveManager } from './moveManager.svelte'
@@ -114,6 +117,7 @@
let showNotes = $state(true)
const triggerContext = getContext<TriggerContext>('TriggerContext')
const overlays = overlayStack()
// Create diffManager instance for this FlowGraphV2
const diffManager = createFlowDiffManager()
@@ -477,10 +481,15 @@
insert: (detail) => {
onInsert?.(detail)
},
select: (modId) => {
select: (modId, opts) => {
// AI tools are not selectable by the flow. Selection has to be refactored to be simplier.
if (nodes.find((n) => n.data?.moduleId === modId)?.type === 'aiTool' || modId === 'Trigger') {
selectionManager.selectId(modId)
// Flow-level panels reach selection only through here, so they must go through
// selectId or their intent (and the modal panel) never fires.
if (
nodes.find((n) => n.data?.moduleId === modId)?.type === 'aiTool' ||
isFlowLevelPanelTarget(modId)
) {
selectionManager.selectId(modId, opts)
}
if (!notSelectable) {
onSelect?.(modId)
@@ -647,6 +656,12 @@
// Keyboard event handling
function handleKeyDown(event: KeyboardEvent) {
// Escape belongs to the topmost overlay. This listener is on `document` and theirs
// are on `window`, so this one always runs first — without the guard, dismissing a
// modal or picker would also clear the selection under it and lose the user's step.
if (event.key === 'Escape' && overlays.val.length > 0) {
return
}
selectionManager.handleKeyDown(event)
noteManager.handleKeyDown(event)
if (event.key === 'Escape') {
@@ -1281,12 +1296,17 @@
{@render leftHeader()}
</div>
{:else}
<!-- Their built-in glyphs are fill-based and sized differently from every other
icon in the editor, so the bar is built from lucide throughout. -->
<Controls
class="wm-flow-controls"
position={controlsPosition === 'bottom' ? 'bottom-right' : 'top-right'}
orientation="horizontal"
showZoom={false}
showFitView={false}
showLock={false}
fitViewOptions={{ nodes: nodes.filter((n) => n.type !== 'note') }}
>
<GraphZoomControls fitViewNodes={nodes.filter((n) => n.type !== 'note')} />
{#if multiSelectEnabled}
<div class="flex items-center gap-2">
<Tooltip>
@@ -1342,6 +1362,7 @@
<Expand size="14" />
</ControlButton>
{/if}
<FlowPanelPlacementPicker variant="control" placement="top-end" />
</Controls>
<Controls
@@ -1374,12 +1395,37 @@
opacity: 0;
}
:global(.svelte-flow__controls-button) {
@apply bg-surface border-0;
/* xy-flow's own rules are nested, so `.svelte-flow__controls-button svg` and the like
match ours exactly and load order decides. Scoping by the class we pass to <Controls>
outranks them instead of racing them. */
:global(.svelte-flow__controls.wm-flow-controls) {
@apply overflow-hidden rounded-md border;
box-shadow: none;
}
:global(.svelte-flow__controls-button:hover) {
:global(.wm-flow-controls .svelte-flow__controls-button) {
@apply bg-surface text-primary;
width: 32px;
height: 30px;
padding: 8px;
}
:global(.wm-flow-controls .svelte-flow__controls-button:hover) {
@apply bg-surface-hover;
}
:global(.wm-flow-controls.horizontal .svelte-flow__controls-button) {
@apply border-r border-gray-200 dark:border-gray-700;
}
:global(.wm-flow-controls.horizontal .svelte-flow__controls-button:last-child) {
@apply border-r-0;
}
/* Every glyph in this bar is lucide, so undo their base `fill: currentColor` — it beats
lucide's inline fill="none" and would flood the stroke icons solid — and lift the
12px cap that keeps them off the editor's icon scale. */
:global(.wm-flow-controls .svelte-flow__controls-button svg) {
max-width: 16px;
max-height: 16px;
fill: none;
stroke: currentColor;
}
:global(.svelte-flow__edgelabel-renderer) {
@apply z-50;
@@ -0,0 +1,35 @@
<script lang="ts">
import { ControlButton, useSvelteFlow, type Node } from '@xyflow/svelte'
import { Maximize, ZoomIn, ZoomOut } from 'lucide-svelte'
import { Tooltip } from '$lib/components/meltComponents'
interface Props {
/** Nodes to frame; notes are decorative and would skew the fit. */
fitViewNodes?: Node[]
}
let { fitViewNodes }: Props = $props()
// Its own component so the hook runs inside the provider, like DragGhost and
// PaneContextMenu do.
const { zoomIn, zoomOut, fitView } = useSvelteFlow()
</script>
<Tooltip>
<ControlButton onclick={() => zoomIn()}>
<ZoomIn size="14" />
</ControlButton>
{#snippet text()}Zoom in{/snippet}
</Tooltip>
<Tooltip>
<ControlButton onclick={() => zoomOut()}>
<ZoomOut size="14" />
</ControlButton>
{#snippet text()}Zoom out{/snippet}
</Tooltip>
<Tooltip>
<ControlButton onclick={() => fitView(fitViewNodes ? { nodes: fitViewNodes } : undefined)}>
<Maximize size="14" />
</ControlButton>
{#snippet text()}Fit view{/snippet}
</Tooltip>
@@ -105,8 +105,10 @@
}
function selectModule(mod: FlowModule) {
selectionManager.selectId(mod.id)
eventHandlers?.select(mod.id)
// These icons stand in for nodes the graph marks unselectable, so there is no
// node to double-click — the click itself has to open the panel.
selectionManager.selectId(mod.id, { openPanel: true })
eventHandlers?.select(mod.id, { openPanel: true })
}
</script>
@@ -4,6 +4,7 @@ import { getAllModules, getDependeeAndDependentComponents } from '../flows/flowE
import { dfsByModule } from '../flows/previousResults'
import { defaultIfEmptyString } from '$lib/utils'
import type { GraphModuleState } from './model'
import type { SelectIntentOptions } from './selectionUtils.svelte'
import { getFlowModuleAssets, type AssetWithAltAccessType } from '../assets/lib'
import { assetDisplaysAsOutputInFlowGraph } from './renderers/nodes/AssetNode.svelte'
import type { ModulesTestStates, ModuleTestState } from '../modulesTest.svelte'
@@ -60,7 +61,7 @@ export type GraphEventHandlers = {
isPreprocessor?: boolean
}) => void
deleteBranch: (detail: { id: string; index: number }, label: string) => void
select: (mod: string | FlowModule) => void
select: (mod: string | FlowModule, opts?: SelectIntentOptions) => void
delete: (detail: { id: string }, label: string) => void
/** Drop a node that only mirrors run state (the error handler marker). Never edits the flow. */
dismissRunNode: (id: string) => void
@@ -84,14 +85,6 @@ export type GraphEventHandlers = {
export type SimplifiableFlow = { simplifiedFlow: boolean }
export function isTriggerStep(module: FlowModule | undefined): boolean {
return (
module?.value != undefined &&
(module.value.type === 'script' || module.value.type === 'rawscript') &&
module.value.is_trigger === true
)
}
export function buildPrefix(prefix: string | undefined, id: string): string {
return (prefix ?? '') + id + ':'
}
@@ -6,7 +6,6 @@
import { getStraightLinePath } from '../utils'
import { twMerge } from 'tailwind-merge'
import { NODE_WITH_WRITE_ASSET_Y_OFFSET } from '../nodes/AssetNode.svelte'
import { workspaceStore } from '$lib/stores'
import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte'
import type { Job } from '$lib/gen'
import type { GraphModuleState } from '../../model'
@@ -150,7 +149,7 @@
{#if data?.flowJob && data.flowJob.flow_status?.modules?.[data.flowJob.flow_status?.step]?.type === 'WaitingForEvents'}
<FlowStatusWaitingForEvents
job={data.flowJob}
workspaceId={$workspaceStore!}
workspaceId={data.flowJob.workspace_id}
isOwner={data.isOwner}
light
/>
@@ -159,7 +158,7 @@
{#each Object.values(suspendStatus) as suspendCount (suspendCount.job.id)}
<FlowStatusWaitingForEvents
job={suspendCount.job}
workspaceId={$workspaceStore!}
workspaceId={suspendCount.job.workspace_id}
isOwner={data.isOwner}
light
/>
@@ -303,9 +303,9 @@
// agent's module id through the manual path would race the agent node's own click selection.
function onSelect() {
if (data.selectTarget) {
selectionManager?.selectId(data.selectTarget)
selectionManager?.selectId(data.selectTarget, { openPanel: true })
}
data.eventHandlers.select(data.selectTarget ?? data.moduleId)
data.eventHandlers.select(data.selectTarget ?? data.moduleId, { openPanel: true })
}
</script>
@@ -6,7 +6,6 @@
import { NoteColor, NOTE_COLORS } from '../../noteColors'
import { NODE } from '../../util'
import { Hourglass } from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte'
import { dfs } from '$lib/components/flows/dfs'
@@ -83,7 +82,7 @@
{#if data.flowJob.flow_status?.modules?.[data.flowJob.flow_status?.step]?.type === 'WaitingForEvents'}
<FlowStatusWaitingForEvents
job={data.flowJob}
workspaceId={$workspaceStore!}
workspaceId={data.flowJob.workspace_id}
isOwner={data.isOwner}
light
/>
@@ -92,7 +91,7 @@
{#each Object.values(data.suspendStatus) as suspendCount (suspendCount.job.id)}
<FlowStatusWaitingForEvents
job={suspendCount.job}
workspaceId={$workspaceStore!}
workspaceId={suspendCount.job.workspace_id}
isOwner={data.isOwner}
light
/>
@@ -1,11 +1,13 @@
<script lang="ts">
import MapItem from '$lib/components/flows/map/MapItem.svelte'
import { GitBranchPlus, Move, Copy, Trash2, StickyNote } from 'lucide-svelte'
import { GitBranchPlus, Move, Copy, Trash2, StickyNote, PictureInPicture2 } from 'lucide-svelte'
import NodeWrapper from './NodeWrapper.svelte'
import type { ModuleN } from '../../graphBuilder.svelte'
import { jobToGraphModuleState } from '$lib/components/modulesTest.svelte'
import { getNoteEditorContext } from '../../noteEditor.svelte'
import { isMac, type Item } from '$lib/utils'
import { getContext } from 'svelte'
import { getGraphContext } from '../../graphContext'
interface Props {
data: ModuleN['data']
@@ -49,9 +51,25 @@
let isPreprocessor = $derived(data.id === 'preprocessor')
// In modal-panel mode (sessions) step details open on double-click, or on a click on the
// already selected step. Surface the action in the ellipsis menu too, with the gesture
// that works from any state as its shortcut.
const stepExploreHint = getContext<(() => boolean) | undefined>('flowGraphStepExploreHint')
const selectionManager = getGraphContext()?.selectionManager
const menuItems: Item[] = $derived(
data.editMode
? [
...(stepExploreHint?.()
? [
{
displayName: 'Open details',
icon: PictureInPicture2,
shortcut: 'Double click',
action: () => selectionManager?.selectId(data.id, { openPanel: true })
}
]
: []),
...(isPreprocessor
? []
: [
@@ -21,100 +21,100 @@
</script>
{#if !isMoving}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<Popover
bind:isOpen={open}
portal={portalTarget()}
contentClasses="p-2 max-w-lg h-[400px] bg-surface"
class="inline-block"
usePointerDownOutside
floatingConfig={{
placement: 'bottom',
strategy: 'absolute',
gutter: 8,
overflowPadding: 16,
flip: true,
fitViewport: true,
overlap: false
}}
>
{#snippet trigger()}
<Button
size="xs3"
variant="default"
nonCaptureEvent
selected={open}
startIcon={{ icon: Plus }}
wrapperClasses="{open
? 'bg-surface-secondary'
: 'bg-surface-tertiary'} transition-colors drop-shadow-base"
btnClasses="gap-1 text-2xs px-1"
>
Tool
</Button>
{/snippet}
{#snippet content({ close })}
<InsertModuleInner
bind:funcDesc
toolMode
on:close={() => {
close()
}}
on:new={(e) => {
data.eventHandlers.insert({
index: -1, // ignored when agentId is set
agentId: data.agentModuleId,
...e.detail
})
close()
}}
on:insert={(e) => {
data.eventHandlers.insert({
index: -1, // ignored when agentId is set
agentId: data.agentModuleId,
...e.detail
})
close()
}}
on:pickScript={(e) => {
data.eventHandlers.insert({
index: -1, // ignored when agentId is set
agentId: data.agentModuleId,
kind: e.detail.kind,
script: {
...e.detail,
summary: e.detail.summary
? e.detail.summary.replace(/\s/, '_').replace(/[^a-zA-Z0-9_]/g, '')
: e.detail.path.split('/').pop()
}
})
close()
}}
on:pickMcpTool={(e) => {
data.eventHandlers.insert({
index: -1,
agentId: data.agentModuleId,
kind: 'mcpTool'
})
close()
}}
on:pickWebsearchTool={(e) => {
data.eventHandlers.insert({
index: -1,
agentId: data.agentModuleId,
kind: 'websearchTool'
})
close()
}}
on:pickAiAgentTool={(e) => {
data.eventHandlers.insert({
index: -1,
agentId: data.agentModuleId,
kind: 'aiAgentTool'
})
close()
}}
/>
{/snippet}
</Popover>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<Popover
bind:isOpen={open}
portal={portalTarget()}
contentClasses="p-2 max-w-full h-[400px] bg-surface"
class="inline-block"
usePointerDownOutside
floatingConfig={{
placement: 'bottom',
strategy: 'absolute',
gutter: 8,
overflowPadding: 16,
flip: true,
fitViewport: true,
overlap: false
}}
>
{#snippet trigger()}
<Button
size="xs3"
variant="default"
nonCaptureEvent
selected={open}
startIcon={{ icon: Plus }}
wrapperClasses="{open
? 'bg-surface-secondary'
: 'bg-surface-tertiary'} transition-colors drop-shadow-base"
btnClasses="gap-1 text-2xs px-1"
>
Tool
</Button>
{/snippet}
{#snippet content({ close })}
<InsertModuleInner
bind:funcDesc
toolMode
on:close={() => {
close()
}}
on:new={(e) => {
data.eventHandlers.insert({
index: -1, // ignored when agentId is set
agentId: data.agentModuleId,
...e.detail
})
close()
}}
on:insert={(e) => {
data.eventHandlers.insert({
index: -1, // ignored when agentId is set
agentId: data.agentModuleId,
...e.detail
})
close()
}}
on:pickScript={(e) => {
data.eventHandlers.insert({
index: -1, // ignored when agentId is set
agentId: data.agentModuleId,
kind: e.detail.kind,
script: {
...e.detail,
summary: e.detail.summary
? e.detail.summary.replace(/\s/, '_').replace(/[^a-zA-Z0-9_]/g, '')
: e.detail.path.split('/').pop()
}
})
close()
}}
on:pickMcpTool={(e) => {
data.eventHandlers.insert({
index: -1,
agentId: data.agentModuleId,
kind: 'mcpTool'
})
close()
}}
on:pickWebsearchTool={(e) => {
data.eventHandlers.insert({
index: -1,
agentId: data.agentModuleId,
kind: 'websearchTool'
})
close()
}}
on:pickAiAgentTool={(e) => {
data.eventHandlers.insert({
index: -1,
agentId: data.agentModuleId,
kind: 'aiAgentTool'
})
close()
}}
/>
{/snippet}
</Popover>
{/if}

Some files were not shown because too many files have changed in this diff Show More