From 80d6fb7eb358cc1a90868e3a0e40f1c2ef804445 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 26 Aug 2026 15:01:34 +0200 Subject: [PATCH] fix: keep the flow graph mounted across step-panel placement changes Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016eu85JrwsztjfXGNQAATRz --- .../src/lib/components/FlowGraphViewer.svelte | 42 ++++--- .../flows/content/FlowModuleComponent.svelte | 2 +- .../lib/components/flows/stepLabel.test.ts | 59 ++++++++++ .../dev/flow_path_viewer/+page.svelte | 110 +++++++++++------- .../dev/flow_path_viewer/fixtureFlow.ts | 2 - 5 files changed, 153 insertions(+), 62 deletions(-) create mode 100644 frontend/src/lib/components/flows/stepLabel.test.ts diff --git a/frontend/src/lib/components/FlowGraphViewer.svelte b/frontend/src/lib/components/FlowGraphViewer.svelte index 56b22d66c5..02d6b6e3ce 100644 --- a/frontend/src/lib/components/FlowGraphViewer.svelte +++ b/frontend/src/lib/components/FlowGraphViewer.svelte @@ -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} + - + {@render graph()} - - {@render side()} - + {#if panelDocked} + + {@render side()} + + {/if} - {:else if !noGraph} - {@render graph()} - {:else if hasSideContent} - {@render side()} {/if} {#if panelMode === 'modal' && !stepModalOpen} @@ -215,7 +223,13 @@ {/if} - + {#snippet titleBadge()} {#if stepModalBadge} {stepModalBadge} diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 28082864aa..70554f2460 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -1077,7 +1077,7 @@ {/if} {:else if flowModule.value.type === 'flow'} {#key forceReload} - + {/key} {/if} {/snippet} diff --git a/frontend/src/lib/components/flows/stepLabel.test.ts b/frontend/src/lib/components/flows/stepLabel.test.ts new file mode 100644 index 0000000000..52b84dc27d --- /dev/null +++ b/frontend/src/lib/components/flows/stepLabel.test.ts @@ -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('') + }) +}) 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 7b6b677fde..b897815926 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 @@ -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 @@ }) -
-
- FlowPathViewer - {path} - - - - - - {#snippet children({ item })} - - - - {/snippet} - - - +{#if !enabled} +
+ This page deploys fixture flows into the current workspace, so it runs only in a dev build.
+{:else} +
+
+ FlowPathViewer + {path} -
-
- {#if error} -
{error}
- {:else if seeded} - {#key seeded} - - {/key} - {:else} -
Seeding the fixture flow…
- {/if} + + + + + {#snippet children({ item })} + + + + {/snippet} + + + +
+ +
+
+ {#if error} +
{error}
+ {:else if seeded} + {#key seeded} + + {/key} + {:else} +
Seeding the fixture flow…
+ {/if} +
-
+{/if} 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 5699e8621d..39b98c498a 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 @@ -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',