From 6b56656bd650f2db5fbd74e23b55b926c6f69dd7 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Fri, 23 Oct 2026 14:47:03 +0200 Subject: [PATCH] fix: keep a staged flow path across reloads and follow moves in session tabs (#11967) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/lib/components/FlowBuilder.svelte | 21 +++++++++---------- frontend/src/lib/components/flow_builder.ts | 6 +++--- .../components/sessions/FlowEditorView.svelte | 4 ++-- .../sessions/RawAppEditorView.svelte | 10 ++++++++- .../sessions/ScriptEditorView.svelte | 2 +- .../flows/edit/[...path]/+page.svelte | 21 ++++++++++++------- 6 files changed, 38 insertions(+), 26 deletions(-) diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index a919fc82f5..86fa1d5864 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -135,8 +135,8 @@ let { initialPath = $bindable(''), /** The draft row's own path (the URL path). Unlike `initialPath`, which the - * route re-seeds from `draft_path` so the topbar shows the pending name, - * this stays where the item actually is. */ + * route re-seeds from a draft-only flow's `draft_path` so the topbar shows + * the pending name, this stays where the item actually is. */ userDraftPath = '', onTakeLatest = undefined, pathStoreInit = undefined, @@ -1436,18 +1436,17 @@ readFieldsRecursively(flowStore.val) } }) - // Sync `$pathStore` from `flowStore.val.path` (which `initFlow` populates - // from the loaded flow — including the draft's rename, when there is one). - // This effect only tracks `flowStore.val.path`, so popover edits that go - // straight to `$pathStore` don't trigger it and aren't overwritten. - // Replaces the previous `$pathStore = initialPath` push (added in #2536 for - // the VSCode extension), which silently dropped any draft-renamed path - // because `initialPath` is the URL, not the loaded path. + // Sync `$pathStore` from the loaded flow: its staged `draft_path` when the + // draft has one, else its `path`. Seeding from `path` alone would make the + // effect below drop a staged move on every reload, and deploy would then + // create at a taken path. Only `flowStore.val.path` is tracked, so popover + // edits that go straight to `$pathStore` aren't overwritten. $effect.pre(() => { // `flowStore.val` is typed `OpenFlow` here but `initFlow` actually puts a // `Flow` (with `path`) in it. - const p = (flowStore.val as Flow | undefined)?.path - if (p) untrack(() => ($pathStore = p)) + const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined + const p = flow?.path + if (p) untrack(() => ($pathStore = flow?.draft_path || p)) }) // Persist the user-typed path into the draft JSON as `draft_path` diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index 1a4b7e5c26..8f2f634643 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -10,9 +10,9 @@ import type { WorkspaceItem } from './workspacePicker' export type FlowBuilderProps = { initialPath?: string - /** The draft row's own path (the URL path). `initialPath` is re-seeded from the - * draft's `draft_path` so the topbar shows the pending name, so it can't be used - * to resolve what is actually deployed. */ + /** The draft row's own path (the URL path). For a draft-only flow `initialPath` is + * re-seeded from the draft's `draft_path` so the topbar shows the pending name, so + * it can't be used to resolve what is actually deployed. */ userDraftPath?: string /** Moves the draft's base to the head and keeps its content; offered in the diff * drawer while the draft is behind, and called with the version the drawer shows diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte index 9e34b1f7a4..ae5e25097d 100644 --- a/frontend/src/lib/components/sessions/FlowEditorView.svelte +++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte @@ -131,10 +131,10 @@ onDetails={onSeeDetails} condensedHeader={true} customUi={{ topBar: { aiBuilder: false } }} - onDeploy={() => { + onDeploy={(e) => { // FlowBuilder has no deploy toast and the session stays put, so toast here. sendUserToast('Deployed') - runtime.itemDeployed(workspaceId, 'flow', path) + runtime.itemDeployed(workspaceId, 'flow', path, e.path) }} /> {/snippet} diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 8bff98802c..81e219f7f4 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -148,7 +148,15 @@ if (dp !== undefined) { surfacedDraftPath = true if (val.draft_path !== dp) val.draft_path = dp - } else if (surfacedDraftPath && val.draft_path !== undefined) { + } else if ( + surfacedDraftPath && + val.draft_path !== undefined && + // Equal to the saved path, it is not stale: a deploy just moved the app + // there. Clearing it would schedule a save of this cell at the old path + // that lands after the deploy discarded it, and the moved draft row + // would come back on the new path. + val.draft_path !== cell.saved.val?.path + ) { val.draft_path = undefined } }) diff --git a/frontend/src/lib/components/sessions/ScriptEditorView.svelte b/frontend/src/lib/components/sessions/ScriptEditorView.svelte index ab1bac52c9..5248f14164 100644 --- a/frontend/src/lib/components/sessions/ScriptEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScriptEditorView.svelte @@ -124,7 +124,7 @@ // Fires on every deploy (primary, "Deploy & Stay here", and lib — we // ignore e.stay since the session always stays). sendUserToast('Deployed') - runtime.itemDeployed(workspaceId, 'script', e.path) + runtime.itemDeployed(workspaceId, 'script', path, e.path) }} /> {/if} diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index cbaf2fd935..8f2ae0b65c 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -339,7 +339,6 @@ v = undefined } if (tok !== loadFlowToken) return - version = v const backendFlow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, @@ -376,6 +375,9 @@ // Layer the draft (`.draft`, if any) over the deployed payload at the field // level. See /scripts/edit's loader for the rationale. const { draft: draftFromBackend, ...deployedFlow } = backendFlow as any + // Set together with the base below: across the await above, a new head beside + // the previous load's base reads as stale, and the stale prompt latches open. + version = v // The flow_version the draft forked from; undefined when editing the deployed // flow directly (no draft) or for a draft never forked from a deploy. draftBaseVersion = backendFlow.draft_base @@ -395,13 +397,16 @@ deployedBaseline = backendFlow.no_deployed ? undefined : (structuredClone($state.snapshot(deployedFlow)) as Flow) - // Surface the saved `draft_path` to the Path widget so the topbar shows the - // pending name, not the `draft_{uuid}` URL. Else the widget seeds from the - // URL, the first edit clobbers `draft_path`, and the friendly name is lost. - // Otherwise the URL, set on every load: this route instance is reused when the - // editor follows its draft to a moved item, and the path it opened on is where - // deploy would look for the flow. - const renderedDraftPath = (effectiveFlow as any).draft_path as string | undefined + // A draft-only flow surfaces its saved `draft_path` to the Path widget so the + // topbar shows the pending name, not the `draft_{uuid}` URL. Else the widget + // seeds from the URL, the first edit clobbers `draft_path`, and the friendly + // name is lost. A deployed flow keeps the URL even with a staged move: it is + // where deploy updates the flow and where its triggers live (FlowBuilder shows + // the staged path). Set on every load: this route instance is reused when the + // editor follows its draft to a moved item. + const renderedDraftPath = backendFlow.no_deployed + ? ((effectiveFlow as any).draft_path as string | undefined) + : undefined flowInitialPath = renderedDraftPath || (page.params.path ?? '') // "Load another user's draft" handoff: render their value over the deployed