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 @@
})
-