fix: keep the flow graph mounted across step-panel placement changes

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 15:01:34 +02:00
co-authored by Claude Opus 5
parent e2defaceed
commit 80d6fb7eb3
5 changed files with 153 additions and 62 deletions
@@ -84,10 +84,11 @@
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
// Moving the panel must not lose what it was showing, so a panel that was on screen
// reopens as a dialog. Docked is not enough on its own: under hideDefaultInputs with
// nothing selected the pane renders nothing, and detaching would open an empty dialog.
// The reverse is handled by the effect below, which closes a dialog no longer rendered.
const wasVisible = (panelMode === 'docked' && hasSideContent) || stepModalOpen
panelController.preference = preference
stepModalOpen = panelController.mode === 'modal' && wasVisible
}
@@ -95,6 +96,7 @@
// 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))
let panelDocked = $derived(hasSideContent && panelMode === 'docked')
// 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.
@@ -190,19 +192,25 @@
bind:clientWidth={availableWidth}
class="w-full h-full min-h-0 relative"
>
{#if !noGraph && hasSideContent && panelMode === 'docked'}
{#if noGraph}
{#if hasSideContent}
{@render side()}
{/if}
{:else}
<!-- The graph keeps one pane across every transition, and only the step pane comes and
goes. Rendering it in two branches instead would re-create FlowGraphV2 whenever the
panel moves — losing pan, zoom and the selected node — and every embed under 1280
would mount it twice, since width starts at 0 and 0 resolves to docked. -->
<Splitpanes class="w-full h-full">
<Pane size={66} minSize={25}>
<Pane size={panelDocked ? 66 : 100} minSize={25}>
{@render graph()}
</Pane>
<Pane size={34} minSize={15}>
{@render side()}
</Pane>
{#if panelDocked}
<Pane size={34} minSize={15}>
{@render side()}
</Pane>
{/if}
</Splitpanes>
{:else if !noGraph}
{@render graph()}
{:else if hasSideContent}
{@render side()}
{/if}
{#if panelMode === 'modal' && !stepModalOpen}
@@ -215,7 +223,13 @@
{/if}
</div>
<Modal bind:open={stepModalOpen} title={stepModalTitle} kind="X" titleBadgeFirst class="max-w-4xl">
<Modal
bind:open={stepModalOpen}
title={stepModalTitle}
kind="X"
titleBadgeFirst
class="sm:max-w-4xl"
>
{#snippet titleBadge()}
{#if stepModalBadge}
<Badge color="indigo" small class="shrink-0 !py-0 leading-4">{stepModalBadge}</Badge>
@@ -1077,7 +1077,7 @@
{/if}
{:else if flowModule.value.type === 'flow'}
{#key forceReload}
<FlowPathViewer path={flowModule.value.path} />
<FlowPathViewer noSide path={flowModule.value.path} />
{/key}
{/if}
{/snippet}
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import type { FlowModule } from '$lib/gen'
import { stepLabel } from './stepLabel'
function mod(id: string, value: any, summary?: string): FlowModule {
return { id, value, ...(summary ? { summary } : {}) } as FlowModule
}
describe('stepLabel', () => {
it('prefers the summary over every derived label', () => {
expect(stepLabel(mod('a', { type: 'forloopflow', parallel: true }, 'Per line item'))).toBe(
'Per line item'
)
expect(stepLabel(mod('failure', { type: 'rawscript', language: 'bun' }, 'Tell Slack'))).toBe(
'Tell Slack'
)
})
// The ordering that a reorder would silently break: the id checks sit between the composite
// types and the leaf script types, so these keep their role rather than reading as their body.
it('names the failure and preprocessor modules by their role, not their script', () => {
expect(stepLabel(mod('failure', { type: 'rawscript', language: 'bun' }))).toBe('Error handler')
expect(stepLabel(mod('preprocessor', { type: 'rawscript', language: 'python3' }))).toBe(
'Preprocessor'
)
})
it('lets a composite type win over the id checks', () => {
expect(stepLabel(mod('failure', { type: 'branchone' }))).toBe('Run one branch')
})
it('appends one parenthesised suffix per set flag, in order', () => {
expect(
stepLabel(
mod('b', { type: 'forloopflow', parallel: true, skip_failures: true, squash: true })
)
).toBe('For loop (parallel) (skip failures) (squash)')
expect(stepLabel(mod('b', { type: 'forloopflow' }))).toBe('For loop')
expect(stepLabel(mod('c', { type: 'whileloopflow', squash: true }))).toBe('While loop (squash)')
expect(stepLabel(mod('d', { type: 'branchall', parallel: true }))).toBe(
'Run all branches (parallel)'
)
})
it('names the leaf types', () => {
expect(stepLabel(mod('e', { type: 'rawscript', language: 'python3' }))).toBe(
'Inline python3 script'
)
expect(stepLabel(mod('f', { type: 'script' }))).toBe('Workspace script')
expect(stepLabel(mod('g', { type: 'aiagent' }))).toBe('AI Agent')
expect(stepLabel(mod('h', { type: 'flow' }))).toBe('Inner flow')
expect(stepLabel(mod('i', { type: 'identity' }))).toBe('Identity')
})
it('returns an empty label for an unknown type rather than throwing', () => {
expect(stepLabel(mod('j', { type: 'something_new' }))).toBe('')
expect(stepLabel(mod('k', undefined))).toBe('')
})
})
@@ -30,13 +30,24 @@
short: 'height: 260px; width: 100%'
}
/** Stamped on every flow this harness deploys, and checked before it overwrites one. */
const FIXTURE_MARK = 'Deployed by /dev/flow_path_viewer. Safe to delete.'
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 {
const body = { path: p, ...flow, description: FIXTURE_MARK, deployment_message: 'dev fixture' }
if (!(await FlowService.existsFlowByPath({ workspace, path: p }))) {
await FlowService.createFlow({ workspace, requestBody: body })
return
}
// A path this harness happens to want can already hold someone's real flow. Redeploying
// over it would be a silent, versioned overwrite, so only ever replace our own.
const existing = await FlowService.getFlowByPath({ workspace, path: p })
if (existing.description !== FIXTURE_MARK) {
throw new Error(
`${p} already exists and was not created by this page — delete it or point the harness elsewhere`
)
}
await FlowService.updateFlow({ workspace, path: p, requestBody: body })
}
async function seed(workspace: string, p: string) {
@@ -58,10 +69,13 @@
}
}
// The page deploys flows into whatever workspace you are in, so it exists only in a dev build.
const enabled = import.meta.env.DEV
$effect(() => {
const workspace = $workspaceStore
const p = path
if (!workspace || !$userStore) return
if (!enabled || !workspace || !$userStore) return
// seed() writes seeded/seeding, so the guard has to read them outside the dependency set
untrack(() => {
if (seeded !== p && !seeding) seed(workspace, p)
@@ -69,47 +83,53 @@
})
</script>
<div class="h-screen w-full flex flex-col min-h-0">
<div class="flex flex-wrap items-center gap-4 border-b px-4 py-2 text-xs bg-surface-secondary">
<span class="font-semibold">FlowPathViewer</span>
<span class="text-tertiary font-mono">{path}</span>
<Toggle bind:checked={noSide} size="xs" options={{ right: 'noSide' }} />
<Toggle
bind:checked={fillAvailableHeight}
size="xs"
options={{ right: 'fillAvailableHeight' }}
/>
<ToggleButtonGroup bind:selected={frame} noWFull>
{#snippet children({ item })}
<ToggleButton value="full" label="Full page" {item} />
<ToggleButton value="panel" label="Raw-app pane (55%)" {item} />
<ToggleButton value="short" label="Short (260px)" {item} />
{/snippet}
</ToggleButtonGroup>
<Button
unifiedSize="xs"
variant="default"
loading={seeding}
onclick={() => $workspaceStore && seed($workspaceStore, path)}
>
Re-seed
</Button>
{#if !enabled}
<div class="p-8 text-sm text-secondary">
This page deploys fixture flows into the current workspace, so it runs only in a dev build.
</div>
{:else}
<div class="h-screen w-full flex flex-col min-h-0">
<div class="flex flex-wrap items-center gap-4 border-b px-4 py-2 text-xs bg-surface-secondary">
<span class="font-semibold">FlowPathViewer</span>
<span class="text-tertiary font-mono">{path}</span>
<div class="grow min-h-0 p-4">
<div class="border rounded min-h-0 flex flex-col overflow-hidden" style={frameStyle[frame]}>
{#if error}
<div class="p-4 text-xs text-red-600 font-mono whitespace-pre-wrap">{error}</div>
{:else if seeded}
{#key seeded}
<FlowPathViewer path={seeded} {noSide} {fillAvailableHeight} />
{/key}
{:else}
<div class="p-4 text-xs text-tertiary">Seeding the fixture flow…</div>
{/if}
<Toggle bind:checked={noSide} size="xs" options={{ right: 'noSide' }} />
<Toggle
bind:checked={fillAvailableHeight}
size="xs"
options={{ right: 'fillAvailableHeight' }}
/>
<ToggleButtonGroup bind:selected={frame} noWFull>
{#snippet children({ item })}
<ToggleButton value="full" label="Full page" {item} />
<ToggleButton value="panel" label="Raw-app pane (55%)" {item} />
<ToggleButton value="short" label="Short (260px)" {item} />
{/snippet}
</ToggleButtonGroup>
<Button
unifiedSize="xs"
variant="default"
loading={seeding}
onclick={() => $workspaceStore && seed($workspaceStore, path)}
>
Re-seed
</Button>
</div>
<div class="grow min-h-0 p-4">
<div class="border rounded min-h-0 flex flex-col overflow-hidden" style={frameStyle[frame]}>
{#if error}
<div class="p-4 text-xs text-red-600 font-mono whitespace-pre-wrap">{error}</div>
{:else if seeded}
{#key seeded}
<FlowPathViewer path={seeded} {noSide} {fillAvailableHeight} />
{/key}
{:else}
<div class="p-4 text-xs text-tertiary">Seeding the fixture flow…</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
@@ -36,8 +36,6 @@ export const subFixtureFlow: 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.',
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',