feat: detach the flow graph step panel below a width breakpoint

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eu85JrwsztjfXGNQAATRz
This commit is contained in:
Guilhem Lemouel
2026-08-26 14:24:26 +02:00
co-authored by Claude Opus 5
parent 21c3fb9aea
commit e2defaceed
6 changed files with 282 additions and 79 deletions
@@ -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<FlowPanelDetachContext>('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 @@
})
</script>
<svelte:window bind:innerWidth />
<div bind:clientHeight={availableHeight} class="w-full h-full min-h-0">
{#if !noGraph && showSide}
<div
bind:clientHeight={availableHeight}
bind:clientWidth={availableWidth}
class="w-full h-full min-h-0 relative"
>
{#if !noGraph && hasSideContent && panelMode === 'docked'}
<Splitpanes class="w-full h-full">
<Pane size={66} minSize={25}>
{@render graph()}
@@ -109,16 +201,54 @@
</Splitpanes>
{:else if !noGraph}
{@render graph()}
{:else if showSide}
{:else if hasSideContent}
{@render side()}
{/if}
{#if panelMode === 'modal' && !stepModalOpen}
<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>
<Modal bind:open={stepModalOpen} title={stepModalTitle} kind="X" titleBadgeFirst class="max-w-4xl">
{#snippet titleBadge()}
{#if stepModalBadge}
<Badge color="indigo" small class="shrink-0 !py-0 leading-4">{stepModalBadge}</Badge>
{/if}
{/snippet}
<!-- The picker in the graph's control bar is behind this dialog, so re-attaching needs its
own way back from in here. -->
{#snippet settings()}
<FlowPanelPlacementPicker variant="header" />
{/snippet}
<!-- The dialog supplies the padding and names the step, and hugs a short step while
scrolling a long one: without fillHeight it sizes to its content, which would
otherwise run past the viewport on a step with a long script. -->
<FlowGraphViewerStep
schema={flow?.schema}
{stepDetail}
{hideDefaultInputs}
{workspace}
hideHeader
class="p-0 max-h-[70vh] overflow-y-auto"
/>
</Modal>
{#snippet graph()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="w-full h-full min-h-0 max-h-full"
class:overflow-auto={overflowAuto}
class:border={!noBorder}
ondblclick={panelMode === 'modal' ? openStepModalFromGraph : undefined}
onpointerdowncapture={panelMode === 'modal' ? noteSelectionBeforeClick : undefined}
onclick={panelMode === 'modal' ? openStepModalIfReselected : undefined}
>
<FlowGraphV2
{triggerNode}
@@ -16,6 +16,7 @@
import { twMerge } from 'tailwind-merge'
import FlowModuleScript from './flows/content/FlowModuleScript.svelte'
import { stepLabel } from './flows/stepLabel'
import { Copy, Expand } from 'lucide-svelte'
import HighlightTheme from './HighlightTheme.svelte'
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
@@ -28,6 +29,10 @@
// The workspace the viewed flow belongs to (differs from the nav workspace in fork/session
// editors); used to qualify resource links.
workspace?: string
/** Overrides the root's padding and scrolling, for hosts that pad and scroll themselves. */
class?: string
/** Drops the id + label strip, for a host that already names the step in its own chrome. */
hideHeader?: boolean
}
let {
@@ -35,7 +40,9 @@
stepDetail = undefined,
jobScriptHash = undefined,
hideDefaultInputs = false,
workspace = undefined
workspace = undefined,
class: className = '',
hideHeader = false
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
let codeViewer: Drawer | undefined = $state()
@@ -94,12 +101,16 @@
</DrawerContent>
</Drawer>
<div class={twMerge('p-2 overflow-y-scroll')}>
<div class={twMerge('p-2 overflow-y-scroll', className)}>
{#if stepDetail == undefined}
<div>
<p class="text-secondary text-xs italic px-2 pt-2"> Click on a step to see its details </p>
{#if !hideHeader}
<p class="text-secondary text-xs italic px-2 pt-2"> Click on a step to see its details </p>
{/if}
{#if schema && !hideDefaultInputs}
<h3 class="mb-2 font-semibold">Flow Inputs</h3>
{#if !hideHeader}
<h3 class="mb-2 font-semibold">Flow Inputs</h3>
{/if}
<SchemaViewer {schema} />
{/if}
</div>
@@ -113,48 +124,23 @@
<p class="font-medium text-secondary text-center pt-4 pb-8"> End of the flow </p>
{:else if typeof stepDetail != 'string' && stepDetail.value}
<div class="">
<div class="sticky top-0 bg-surface w-full flex items-center py-2">
{#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'}
<Badge color="indigo">
{stepDetail.id}
</Badge>
{/if}
<span
class={twMerge(
'font-semibold text-emphasis text-sm',
stepDetail.id !== 'failure' && stepDetail.id !== 'preprocessor' ? 'ml-2' : ''
)}
>
{#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}
<div class="sticky top-0 bg-surface w-full flex items-center py-2">
{#if stepDetail.id && stepDetail.id != 'failure' && stepDetail.id != 'preprocessor'}
<Badge color="indigo">
{stepDetail.id}
</Badge>
{/if}
</span>
</div>
<span
class={twMerge(
'font-semibold text-emphasis text-sm',
stepDetail.id !== 'failure' && stepDetail.id !== 'preprocessor' ? 'ml-2' : ''
)}
>
{stepLabel(stepDetail)}
</span>
</div>
{/if}
{#if stepDetail.value.type == 'script'}
<div class="pb-2">
<a
@@ -50,6 +50,9 @@
/** Rendered against the dialog's own name, before any level below it: what it marks is the
* dialog rather than wherever in it you have navigated to. */
titleBadge?: import('svelte').Snippet
/** Puts the badge ahead of the title, for a badge that identifies the subject rather than
* qualifying it — an id reads before the name it belongs to, a "Beta" tag reads after. */
titleBadgeFirst?: boolean
settings?: import('svelte').Snippet
children?: import('svelte').Snippet
actions?: import('svelte').Snippet
@@ -67,6 +70,7 @@
fillHeight = false,
minZIndex: minZIndexProp = undefined,
titleBadge,
titleBadgeFirst = false,
settings,
children: children_render,
actions
@@ -259,14 +263,26 @@
{@render settings?.()}
</div>
{:else}
<div class="flex flex-row items-center justify-between">
<!-- pr-8 under `kind="X"`: the close button is absolutely positioned, so a long
title or anything in `settings` would otherwise run under it. -->
<div
class="flex flex-row items-center justify-between gap-2 min-w-0 {kind ===
'X'
? 'pr-8'
: ''}"
>
<h3
class="text-emphasis text-lg font-semibold {titleBadge
? 'flex items-center gap-1'
? 'flex items-center gap-1.5 min-w-0'
: ''}"
>
{title}
{@render titleBadge?.()}
{#if titleBadgeFirst}
{@render titleBadge?.()}
<span class="truncate">{title}</span>
{:else}
{title}
{@render titleBadge?.()}
{/if}
</h3>
{@render settings?.()}
</div>
@@ -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<string, any> | 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 ''
}
@@ -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
@@ -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 = {
}
]
}
}
})