mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
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:
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user