diff --git a/frontend/src/lib/components/FlowGraphViewer.svelte b/frontend/src/lib/components/FlowGraphViewer.svelte index edcbf9cf26..56b22d66c5 100644 --- a/frontend/src/lib/components/FlowGraphViewer.svelte +++ b/frontend/src/lib/components/FlowGraphViewer.svelte @@ -7,6 +7,13 @@ import { writable } from 'svelte/store' import { twMerge } from 'tailwind-merge' import { Pane, Splitpanes } from 'svelte-splitpanes' + import { MousePointerClick } from 'lucide-svelte' + import Modal from './common/modal/Modal.svelte' + import Badge from './common/badge/Badge.svelte' + import FlowPanelPlacementPicker from './flows/common/FlowPanelPlacementPicker.svelte' + import { useFlowPanelMode } from './flows/flowPanelMode.svelte' + import type { FlowPanelDetachContext } from './flows/types' + import { stepLabel } from './flows/stepLabel' import FlowGraphViewerStep from './FlowGraphViewerStep.svelte' import FlowGraphV2 from './graph/FlowGraphV2.svelte' @@ -55,12 +62,95 @@ }: Props = $props() let availableHeight = $state(0) - // The step panel stays hidden below Tailwind's sm breakpoint, as it did when this was a - // `hidden sm:flex` grid cell: a Pane keeps its width even when its content is display:none, - // so the breakpoint has to decide whether the Pane exists at all. - let innerWidth = $state(0) - let showSide = $derived( - !noSide && !(hideDefaultInputs && stepDetail == undefined) && innerWidth >= 640 + let availableWidth = $state(0) + + // Same placement rule as the flow editor, off the same breakpoint: 'docked' puts the step + // panel in a pane beside the graph, 'modal' gives the graph the full width and moves the + // panel into a dialog opened by double-clicking a step. Measured on this component rather + // than the window, because the viewer is often embedded in a pane far narrower than it. + const panelController = useFlowPanelMode({ enabled: () => !noSide && !noGraph }) + $effect(() => panelController.measure(availableWidth)) + let panelMode = $derived(panelController.mode) + let stepModalOpen = $state(false) + + // Supplying this context is what puts the Auto/Attached/Detached picker in the graph's own + // control bar — FlowGraphV2 renders it already and hides it wherever the context is absent. + setContext('flowPanelDetach', { + // The viewer's panel draws no card header, so nothing claims the chrome. + claim: () => () => {}, + modalOpen: () => panelMode === 'modal' && stepModalOpen, + close: () => (stepModalOpen = false), + enabled: () => !noSide && !noGraph, + preference: () => panelController.preference, + setPreference: (preference) => { + if (preference === panelController.preference) return + // Moving the panel must not lose what it was showing: docked, it is always on screen, + // so the dialog it becomes has to open on arrival. The reverse is handled by the + // effect below, which closes a dialog that is no longer rendered. + const wasVisible = panelMode === 'docked' || stepModalOpen + panelController.preference = preference + stepModalOpen = panelController.mode === 'modal' && wasVisible + } + }) + // Whether the panel has anything to show. Kept apart from panelMode so the double-click + // gesture stays live under hideDefaultInputs, where nothing shows until a step is picked. + let hasSideContent = $derived(!noSide && !(hideDefaultInputs && stepDetail == undefined)) + + // A move back to 'docked' — the viewer got wider — would otherwise leave a dialog open + // over a panel that is already visible beside the graph. + $effect(() => { + if (panelMode === 'docked' && untrack(() => stepModalOpen)) { + stepModalOpen = false + } + }) + + // Asset and note nodes are deliberately unselectable, and the In/Out bar inside a node is + // a picker that opens and shuts on click — neither is a request to see a step's details. + 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 openStepModalFromGraph(e: MouseEvent) { + if (selectableNodeAt(e)) stepModalOpen = true + } + + // Clicking the step that is already selected is the second half of "select it, then show + // it". Read in the capture phase: once the click bubbles, the graph has applied its own + // selection and a first click looks identical to this one. + let clickStartedOnSelected = false + function noteSelectionBeforeClick(e: MouseEvent) { + clickStartedOnSelected = Boolean(selectableNodeAt(e)?.classList.contains('selected')) + } + + function openStepModalIfReselected(e: MouseEvent) { + if (clickStartedOnSelected && selectableNodeAt(e)) stepModalOpen = true + } + + let stepModalStep = $derived( + typeof stepDetail === 'object' && stepDetail != undefined ? stepDetail : undefined + ) + // Flow-level targets ('Input', 'Result', …) reach the panel as a bare string and have no id + // or label of their own — the string is the name. With nothing selected the panel shows the + // flow's inputs, which is what detaching from an empty selection opens on. + let stepModalTitle = $derived( + stepModalStep + ? stepLabel(stepModalStep) + : typeof stepDetail === 'string' + ? stepDetail + : 'Flow inputs' + ) + let stepModalBadge = $derived( + stepModalStep?.id && stepModalStep.id != 'failure' && stepModalStep.id != 'preprocessor' + ? stepModalStep.id + : undefined + ) + + let stepHintText = $derived( + typeof stepDetail === 'object' && stepDetail != undefined + ? 'Click the selected step to see its details' + : 'Double click a step to see its details' ) if (provideTriggerContext && !hasContext('TriggerContext')) { @@ -95,10 +185,12 @@ }) - - -
- {#if !noGraph && showSide} +
+ {#if !noGraph && hasSideContent && panelMode === 'docked'} {@render graph()} @@ -109,16 +201,54 @@ {:else if !noGraph} {@render graph()} - {:else if showSide} + {:else if hasSideContent} {@render side()} {/if} + + {#if panelMode === 'modal' && !stepModalOpen} +
+ + {stepHintText} +
+ {/if}
+ + {#snippet titleBadge()} + {#if stepModalBadge} + {stepModalBadge} + {/if} + {/snippet} + + {#snippet settings()} + + {/snippet} + + + + {#snippet graph()} + +
-
+
{#if stepDetail == undefined}
-

Click on a step to see its details

+ {#if !hideHeader} +

Click on a step to see its details

+ {/if} {#if schema && !hideDefaultInputs} -

Flow Inputs

+ {#if !hideHeader} +

Flow Inputs

+ {/if} {/if}
@@ -113,48 +124,23 @@

End of the flow

{:else if typeof stepDetail != 'string' && stepDetail.value}
-
- {#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'} - - {stepDetail.id} - - {/if} - - {#if stepDetail.summary} - {stepDetail.summary} - {:else if stepDetail.value.type == 'identity'} - Identity - {:else if stepDetail.value.type == 'forloopflow'} - For loop {#if stepDetail.value.parallel}(parallel){/if} - {#if stepDetail.value.skip_failures}(skip failures){/if} - {#if stepDetail.value.squash}(squash){/if} - {:else if stepDetail.value.type == 'branchall'} - Run all branches {#if stepDetail.value.parallel}(parallel){/if} - {:else if stepDetail.value.type == 'branchone'} - Run one branch - {:else if stepDetail.value.type == 'flow'} - Inner flow - {:else if stepDetail.value.type == 'whileloopflow'} - While loop {#if stepDetail.value.skip_failures}(skip failures){/if} - {#if stepDetail.value.squash}(squash){/if} - {:else if stepDetail.id === 'failure'} - Error handler - {:else if stepDetail.id === 'preprocessor'} - Preprocessor - {:else if stepDetail.value.type == 'rawscript'} - Inline {stepDetail.value.language} script - {:else if stepDetail.value.type == 'script'} - Workspace script - {:else if stepDetail.value.type == 'aiagent'} - AI Agent + {#if !hideHeader} +
+ {#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'} + + {stepDetail.id} + {/if} - -
+ + {stepLabel(stepDetail)} + +
+ {/if} {#if stepDetail.value.type == 'script'}
{:else} -
+ +

- {title} - {@render titleBadge?.()} + {#if titleBadgeFirst} + {@render titleBadge?.()} + {title} + {:else} + {title} + {@render titleBadge?.()} + {/if}

{@render settings?.()}
diff --git a/frontend/src/lib/components/flows/stepLabel.ts b/frontend/src/lib/components/flows/stepLabel.ts new file mode 100644 index 0000000000..55c8a0e353 --- /dev/null +++ b/frontend/src/lib/components/flows/stepLabel.ts @@ -0,0 +1,55 @@ +import type { FlowModule } from '$lib/gen' + +/** + * What to call a step that has no summary. The id checks sit between the composite types and + * the leaf script types on purpose: a failure module holds a rawscript, and reading it as + * "Inline python3 script" loses the only thing that distinguishes it. + */ +export function stepLabel(step: FlowModule): string { + if (step.summary) return step.summary + + const value = step.value as Record | undefined + const suffixes = (...flags: [boolean | undefined, string][]) => + flags + .filter(([on]) => on) + .map(([, label]) => ` (${label})`) + .join('') + + switch (value?.type) { + case 'identity': + return 'Identity' + case 'forloopflow': + return ( + 'For loop' + + suffixes( + [value.parallel, 'parallel'], + [value.skip_failures, 'skip failures'], + [value.squash, 'squash'] + ) + ) + case 'branchall': + return 'Run all branches' + suffixes([value.parallel, 'parallel']) + case 'branchone': + return 'Run one branch' + case 'flow': + return 'Inner flow' + case 'whileloopflow': + return ( + 'While loop' + suffixes([value.skip_failures, 'skip failures'], [value.squash, 'squash']) + ) + } + + if (step.id === 'failure') return 'Error handler' + if (step.id === 'preprocessor') return 'Preprocessor' + + switch (value?.type) { + case 'rawscript': + return `Inline ${value.language} script` + case 'script': + return 'Workspace script' + case 'aiagent': + return 'AI Agent' + } + + return '' +} diff --git a/frontend/src/routes/(root)/(logged)/dev/flow_path_viewer/+page.svelte b/frontend/src/routes/(root)/(logged)/dev/flow_path_viewer/+page.svelte index 6c5c284456..7b6b677fde 100644 --- a/frontend/src/routes/(root)/(logged)/dev/flow_path_viewer/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/dev/flow_path_viewer/+page.svelte @@ -4,11 +4,11 @@ import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import FlowPathViewer from '$lib/components/flows/content/FlowPathViewer.svelte' - import { FlowService } from '$lib/gen' + import { FlowService, type OpenFlow } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { untrack } from 'svelte' - import { fixtureFlow } from './fixtureFlow' + import { fixtureFlow, subFixtureFlow } from './fixtureFlow' // FlowPathViewer takes a path and fetches, so the fixture has to exist in the workspace. // Seeding on load keeps fixtureFlow.ts the single source of truth for the graph. @@ -30,23 +30,23 @@ short: 'height: 260px; width: 100%' } + async function upsert(workspace: string, p: string, flow: OpenFlow) { + const body = { path: p, ...flow, deployment_message: 'dev fixture' } + if (await FlowService.existsFlowByPath({ workspace, path: p })) { + await FlowService.updateFlow({ workspace, path: p, requestBody: body }) + } else { + await FlowService.createFlow({ workspace, requestBody: body }) + } + } + async function seed(workspace: string, p: string) { seeding = true error = undefined try { - const exists = await FlowService.existsFlowByPath({ workspace, path: p }) - if (exists) { - await FlowService.updateFlow({ - workspace, - path: p, - requestBody: { path: p, ...fixtureFlow, deployment_message: 'dev fixture' } - }) - } else { - await FlowService.createFlow({ - workspace, - requestBody: { path: p, ...fixtureFlow, deployment_message: 'dev fixture' } - }) - } + // Subflow first: the main fixture's step 'm' points at it, and a step whose target + // does not exist renders as not-found instead of a nested graph. + await upsert(workspace, `${p}_sub`, subFixtureFlow) + await upsert(workspace, p, fixtureFlow(`${p}_sub`)) seeded = undefined await new Promise((r) => setTimeout(r, 0)) seeded = p diff --git a/frontend/src/routes/(root)/(logged)/dev/flow_path_viewer/fixtureFlow.ts b/frontend/src/routes/(root)/(logged)/dev/flow_path_viewer/fixtureFlow.ts index ad826c4fa9..5699e8621d 100644 --- a/frontend/src/routes/(root)/(logged)/dev/flow_path_viewer/fixtureFlow.ts +++ b/frontend/src/routes/(root)/(logged)/dev/flow_path_viewer/fixtureFlow.ts @@ -17,13 +17,24 @@ function step( const js = (expr: string) => ({ type: 'javascript' as const, expr }) +/** Target of the fixture's subflow step, so the step panel has a nested graph to draw. */ +export const subFixtureFlow: OpenFlow = { + summary: 'Refund a line item (dev fixture subflow)', + value: { + modules: [ + step('a', 'Void the charge', 'bun', 'export async function main() {}\n'), + step('b', 'Restock the item', 'python3', 'def main():\n return "restocked"\n') + ] + } +} + /** * Shape of the graph this fixture draws, so a change here can be judged against intent: * a straight step, a for-loop with two nested steps, a three-way branchone, a branchall - * with a skip_failure branch, a step carrying retry/cache/early-stop badges, plus a - * failure module, a preprocessor, a note and a group. + * with a skip_failure branch, a subflow, a step carrying retry/cache/early-stop badges, + * plus a failure module, a preprocessor, a note and a group. */ -export const fixtureFlow: OpenFlow = { +export const fixtureFlow = (subflowPath: string): OpenFlow => ({ summary: 'Order fulfilment (dev fixture)', description: 'Fake flow rendered by /dev/flow_path_viewer. Edit fixtureFlow.ts and hit Re-seed to change the graph.', @@ -101,6 +112,11 @@ export const fixtureFlow: OpenFlow = { ] } }, + { + id: 'm', + summary: 'Refund rejected items', + value: { type: 'flow', input_transforms: {}, path: subflowPath } + }, step('l', 'Close the order', 'python3', 'def main():\n return "done"\n', { retry: { constant: { attempts: 3, seconds: 5 } }, cache_ttl: 3600, @@ -138,4 +154,4 @@ export const fixtureFlow: OpenFlow = { } ] } -} +})