From 41b62efd6ca5baa1d8cb8e00ad2ab3cba1d03a70 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Fri, 19 Jun 2026 09:08:59 +0200 Subject: [PATCH] fix: unify draft path on content `path`, drop redundant `draft_path` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editors used to store a renamed draft's target path in a separate `draft_path` field (flows/apps/raw-apps) while keeping the deployed path in the content's own `path`. This was redundant — the original/deployed path is the URL — and it broke the "path already used" guard: editors passed the renamed `draft_path` as the Path widget's `initialPath`, so reverting a draft back to the original path falsely tripped "path already used". Now every editor binds the Path widget to the content's own `path` (like the script editor already did), and `initialPath` is always the deployed/original path. A never-deployed draft seeds the baseline from its own friendly path so the synthetic `draft_{uuid}` storage key isn't flagged as a rename. The computed `draft_path` field on list/draft API responses is kept (it's how the home/Review/Drafts lists render the friendly name) but is now sourced uniformly from `value->>'path'` for all kinds. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/windmill-api-flows/src/flows.rs | 8 ++-- backend/windmill-api/openapi-deref.json | 6 +-- backend/windmill-api/openapi-deref.yaml | 10 ++--- backend/windmill-api/openapi.yaml | 23 ++++++------ backend/windmill-api/src/apps.rs | 8 ++-- backend/windmill-api/src/drafts.rs | 29 ++++++--------- backend/windmill-types/src/flows.rs | 2 +- backend/windmill-types/src/scripts.rs | 6 +-- .../src/lib/components/FlowBuilder.svelte | 26 ++++++------- .../src/lib/components/FlowWrapper.svelte | 6 +-- .../apps/editor/AppEditorHeader.svelte | 20 ++++++---- .../apps/editor/AppEditorHeaderDeploy.svelte | 2 +- frontend/src/lib/components/apps/types.ts | 17 +++++---- .../components/raw_apps/RawAppEditor.svelte | 4 +- .../raw_apps/RawAppEditorHeader.svelte | 2 +- .../sessions/DraftDiffDrawer.svelte | 5 ++- .../components/sessions/FlowEditorView.svelte | 15 +++++--- .../sessions/RawAppEditorView.svelte | 20 +++++----- .../sessions/SessionEditorTarget.svelte | 2 +- .../components/sessions/appDraftCodec.test.ts | 21 +++++++---- .../lib/components/sessions/appDraftCodec.ts | 32 +++++++++------- .../components/sessions/flowDraftSig.test.ts | 12 ++---- .../lib/components/sessions/flowDraftSig.ts | 12 +++--- .../sessions/sessionDraftCodecs.test.ts | 27 ++++---------- .../components/sessions/sessionDraftCodecs.ts | 35 ++++++------------ .../sessions/sessionRuntime.svelte.ts | 16 +++----- frontend/src/lib/rawAppDeploy.ts | 6 +-- frontend/src/lib/utils_draft_deploy.ts | 36 ++++++++---------- frontend/src/lib/workspaceDrafts.svelte.ts | 6 +-- .../(logged)/apps/edit/[...path]/+page.svelte | 2 +- .../apps_raw/edit/[...path]/+page.svelte | 37 +++++++++---------- .../flows/edit/[...path]/+page.svelte | 22 ++++++++--- 32 files changed, 224 insertions(+), 251 deletions(-) diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index b5f8e2f285..5d93c151e9 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -269,11 +269,11 @@ async fn list_flows( for row in draft_only_rows { let v: serde_json::Value = serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); - // The Path widget binds `$pathStore` one-way (`flow.path → $pathStore`), - // so the editor writes a separate `draft_path` field only when the typed - // path differs from the deployed one. `None` = unchanged. + // The Path widget binds the flow's own `path`, so the draft JSON's + // `path` IS the user-typed target. Surface it when it differs from + // the storage path. `None` = unchanged. let draft_path = v - .get("draft_path") + .get("path") .and_then(|s| s.as_str()) .filter(|s| !s.is_empty() && *s != row.path.as_str()) .map(|s| s.to_string()); diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 135131af41..ce88e6768e 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -12212,7 +12212,7 @@ }, "draft_path": { "type": "string", - "description": "User-typed friendly path from the draft JSON's `draft_path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`)." + "description": "User-typed friendly path from the draft JSON's own `path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`)." }, "draft_only": { "type": "boolean", @@ -14994,7 +14994,7 @@ }, "draft_path": { "type": "string", - "description": "User-typed path the editor has staged but not\nyet deployed. Sourced from the draft JSON's\n`draft_path` field (the editor only writes it\nwhen the typed path differs from the deployed\none). Lets the home list render the meaningful\nname instead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted when\nunchanged.\n" + "description": "User-typed path the editor has staged but not\nyet deployed. Sourced from the draft JSON's own `path`\n(the editor only writes it\nwhen the typed path differs from the deployed\none). Lets the home list render the meaningful\nname instead of the autogenerated\n`u/{user}/draft_{uuid}` URL path. Omitted when\nunchanged.\n" }, "draft_users": { "description": "Workspace users (including the authed user, and\nthe legacy NULL-email row if any) who have a\nper-user draft at this path. Drives the home\npage's user-avatar circles inside the Draft\nbadge. Omitted when no drafts exist.\n", @@ -43207,7 +43207,7 @@ }, "draft_path": { "type": "string", - "description": "User-typed path the editor has staged but not yet deployed.\nSourced from the draft JSON's `draft_path` field (the editor\nonly writes it when the typed path differs from the deployed\none). Lets the home list render the meaningful name instead of\nthe autogenerated `u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n" + "description": "User-typed path the editor has staged but not yet deployed.\nSourced from the draft JSON's own `path` (the editor\nonly writes it when the typed path differs from the deployed\none). Lets the home list render the meaningful name instead of\nthe autogenerated `u/{user}/draft_{uuid}` URL path. Omitted\nwhen unchanged.\n" }, "draft_users": { "description": "Workspace users (including the authed user, and the legacy\nNULL-email row if any) who have a per-user draft at this\npath. Drives the home page's user-avatar circles inside the\nDraft badge. Omitted when no drafts exist.\n", diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index e45131d81b..b0bfcc68f2 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -12761,8 +12761,8 @@ paths: draft_path: type: string description: >- - User-typed friendly path from the draft JSON's - `draft_path`, when set and different from the storage + User-typed friendly path from the draft JSON's own + `path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`). draft_only: @@ -15562,8 +15562,8 @@ paths: type: string description: | User-typed path the editor has staged but not - yet deployed. Sourced from the draft JSON's - `draft_path` field (the editor only writes it + yet deployed. Sourced from the draft JSON's own + `path` (the editor only writes it when the typed path differs from the deployed one). Lets the home list render the meaningful name instead of the autogenerated @@ -16775,7 +16775,7 @@ paths: User-typed path the editor has staged but not yet deployed. - Sourced from the draft JSON's `draft_path` field (the + Sourced from the draft JSON's own `path` (the editor only writes it when the typed path differs from the diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0a6bdaffa9..63e3a32fce 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7934,7 +7934,7 @@ paths: description: Best-effort, read from the draft JSON's `summary` field when the editor shape carries one. draft_path: type: string - description: User-typed friendly path from the draft JSON's `draft_path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`). + description: User-typed friendly path from the draft JSON's own `path`, when set and different from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`). draft_only: type: boolean description: No deployed counterpart exists at this path — the draft is the whole item. @@ -9702,13 +9702,12 @@ paths: type: string description: | User-typed path the editor has staged but not - yet deployed. Sourced from the draft JSON's - `draft_path` field (the editor only writes it - when the typed path differs from the deployed - one). Lets the home list render the meaningful - name instead of the autogenerated - `u/{user}/draft_{uuid}` URL path. Omitted when - unchanged. + yet deployed. Sourced from the draft JSON's own + `path` (the editor only writes it when the typed + path differs from the deployed one). Lets the home + list render the meaningful name instead of the + autogenerated `u/{user}/draft_{uuid}` URL path. + Omitted when unchanged. draft_users: description: | Workspace users (including the authed user, and @@ -27400,10 +27399,10 @@ components: type: string description: | User-typed path the editor has staged but not yet deployed. - Sourced from the draft JSON's `draft_path` field (the editor - only writes it when the typed path differs from the deployed - one). Lets the home list render the meaningful name instead of - the autogenerated `u/{user}/draft_{uuid}` URL path. Omitted + Sourced from the draft JSON's own `path` (the editor only writes + it when the typed path differs from the deployed one). Lets the + home list render the meaningful name instead of the autogenerated + `u/{user}/draft_{uuid}` URL path. Omitted when unchanged. draft_users: description: | diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 3cb2a1aef6..550373ed1c 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -171,7 +171,7 @@ pub struct ListableApp { /// over the deployed row). See ListableScript in windmill-types/src/scripts.rs. #[serde(default, skip_serializing_if = "is_false")] pub is_draft: bool, - /// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged. + /// User-typed staged path from the draft JSON's own `path`; `None` = unchanged. /// See ListableScript in windmill-types/src/scripts.rs. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] @@ -505,10 +505,10 @@ async fn list_apps( for row in draft_only_rows { let v: serde_json::Value = serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); - // App/raw-app drafts are the bare editor value with no `path`, so the editor - // writes a separate `draft_path` only when it differs from deployed; see flows.rs. + // App/raw-app drafts carry the user-typed path in the value's own + // `path`; surface it when it differs from the storage path. See flows.rs. let draft_path = v - .get("draft_path") + .get("path") .and_then(|s| s.as_str()) .filter(|s| !s.is_empty() && *s != row.path.as_str()) .map(|s| s.to_string()); diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 5117286b96..e5c04f7b13 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -36,11 +36,11 @@ pub struct DraftListItem { /// Best-effort, read from the draft JSON's `summary` field when present. #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, - /// User-typed friendly path read from the draft JSON's `draft_path` (set by - /// the editors when it differs from the storage path, e.g. a never-deployed - /// item parked at `u/{user}/draft_{uuid}`). `None` when absent. Lets the - /// review page show the friendly name instead of the storage path, like the - /// home-page list endpoints. + /// User-typed friendly path read from the draft JSON's own `path`, when it + /// differs from the storage path (e.g. a never-deployed item parked at + /// `u/{user}/draft_{uuid}`). `None` when absent. Lets the review page show + /// the friendly name instead of the storage path, like the home-page list + /// endpoints. #[serde(skip_serializing_if = "Option::is_none")] pub draft_path: Option, /// No deployed counterpart exists at this path — the draft is the whole @@ -195,19 +195,12 @@ fn list_drafts_query(all_users: bool) -> String { d.created_at, d.value ->> 'summary' AS summary, {draft_users} AS draft_users, - -- Friendly typed path, by kind (mirrors the home-page list - -- endpoints): scripts bind the Path widget to `script.path`, - -- so it round-trips through the draft JSON's own `path`; - -- flows/apps/raw-apps carry a separate `draft_path`. NULLIF - -- drops it when empty or equal to the storage path. - NULLIF( - NULLIF( - CASE WHEN d.typ::text = 'script' - THEN d.value ->> 'path' - ELSE d.value ->> 'draft_path' END, - ''), - d.path - ) AS draft_path, + -- Friendly typed path: every editor binds the Path widget to + -- the content's own `path`, so the draft JSON's `path` IS the + -- user-typed target (a never-deployed item is parked at a + -- synthetic draft storage key). NULLIF drops it when empty or + -- equal to the storage path. + NULLIF(NULLIF(d.value ->> 'path', ''), d.path) AS draft_path, (d.email IS NULL) AS legacy_draft, (d.email = $2 OR d.email IS NULL) AS mine, {case} AS draft_only diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 2d1a538124..4aad300c55 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -97,7 +97,7 @@ pub struct ListableFlow { /// over the deployed row). See ListableScript in scripts.rs. #[serde(default)] pub is_draft: bool, - /// User-typed staged path from the draft JSON's `draft_path`; `None` = unchanged. + /// User-typed staged path from the draft JSON's own `path`; `None` = unchanged. /// See ListableScript in scripts.rs. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index 5e5c7f313e..2b07fef5e8 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -470,9 +470,9 @@ pub struct ListableScript { #[serde(skip_serializing_if = "is_false")] pub is_draft: bool, /// User-typed staged path, so the home list shows a meaningful name over the - /// autogenerated `u/{user}/draft_{uuid}`. Sourced from the draft JSON: scripts use - /// `value.path` (the Path widget binds `script.path`); flows/apps/raw apps use an - /// explicit `value.draft_path` written only when it differs from deployed. `None` = unchanged. + /// autogenerated `u/{user}/draft_{uuid}`. Every editor binds the Path widget to + /// the content's own `path`, so it's sourced from the draft JSON's `value.path` + /// when it differs from the storage path. `None` = unchanged. #[sqlx(default)] #[serde(skip_serializing_if = "Option::is_none")] pub draft_path: Option, diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 4ba68d6586..e53226d4b7 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -938,25 +938,21 @@ if (p) untrack(() => ($pathStore = p)) }) - // Persist the user-typed path into the draft JSON as `draft_path` - // when it differs from the deployed/seeded `flow.path`. The Path - // widget binds `$pathStore` one-way to the popover input — without - // this, the friendly auto-name on `/flows/add` and any in-place - // rename never reach the autosaved Flow, so the home-list draft row - // kept showing the autogenerated `u/{user}/draft_{uuid}` slot. Drop - // the field once it matches the baseline again so it doesn't - // linger after a revert; deploy clears the whole draft, so the - // field naturally disappears post-deploy too. + // Mirror the user-typed path into the autosaved Flow's own `path`. The Path + // widget binds `$pathStore` (the popover input); the effect above seeds it + // one-way from `flow.path`, and this one writes edits back — so `flow.path` + // IS the typed target (like the script editor's `script.path`). Without it, + // the friendly auto-name on `/flows/add` and any in-place rename never reach + // the autosaved Flow, so the home-list draft row keeps showing the + // autogenerated `u/{user}/draft_{uuid}` slot. The deployed/original path is + // `initialPath` (the URL), not `flow.path`. Deploy clears the whole draft. $effect(() => { const typed = $pathStore - const baseline = (flowStore.val as Flow | undefined)?.path ?? '' - const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined + const flow = flowStore.val as Flow | undefined if (!flow) return untrack(() => { - if (typed && typed !== baseline) { - flow.draft_path = typed - } else if (flow.draft_path !== undefined) { - delete (flow as any).draft_path + if (typed != undefined && typed !== '' && flow.path !== typed) { + flow.path = typed } }) }) diff --git a/frontend/src/lib/components/FlowWrapper.svelte b/frontend/src/lib/components/FlowWrapper.svelte index 6eaf199ea7..11c275533b 100644 --- a/frontend/src/lib/components/FlowWrapper.svelte +++ b/frontend/src/lib/components/FlowWrapper.svelte @@ -26,9 +26,9 @@ } // Stable per-user draft storage key. Captured once so editing the flow's path - // (which lives in `draft_path`, not the storage key) can't re-key the autosave - // handle and orphan the draft. Mirrors the full-page editor keying on the URL - // path; falls back through the SDK's path inputs. + // (the typed name lives in the flow value's own `path`, not the storage key) + // can't re-key the autosave handle and orphan the draft. Mirrors the full-page + // editor keying on the URL path; falls back through the SDK's path inputs. const draftStoragePath = untrack( () => props.initialPath || diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index ca4ae77be4..5191e8cc97 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -176,19 +176,23 @@ darkMode } = getContext('AppViewerContext') - // Mirror the user-typed path into the draft as `draft_path` when it differs - // from the baseline, so the home row shows the friendly name instead of - // `draft_{uuid}`. Drop the field once it matches the baseline again. + // Mirror the user-typed path into the autosaved App's own `path` when it + // differs from the deployed baseline, so the home row shows the friendly name + // instead of the `draft_{uuid}` storage key (and the backend reads it from + // `value->>'path'`). Only-when-different keeps an unedited draft byte-equal to + // the deployed value (which carries no `path`) so it still dedups/discards; + // drop the field once it matches the baseline again. The deployed/original + // path is the URL, not this field. $effect(() => { const typed = newEditedPath const baseline = savedApp?.path ?? '' - const a = $app + const a = $app as (App & { path?: string }) | undefined if (!a) return untrack(() => { if (typed && typed !== baseline) { - a.draft_path = typed - } else if (a.draft_path !== undefined) { - delete a.draft_path + a.path = typed + } else if (a.path !== undefined) { + delete a.path } }) }) @@ -197,7 +201,7 @@ // autosave stores the bare App value, which has no summary of its own — it // lives in the `app` table column, set only on deploy). Without this the // summary is lost when reopening a draft or deploying it from the Review & - // Deploy page. Parallels `draft_path`. + // Deploy page. Parallels the path mirror above. $effect(() => { const s = $summary const a = $app diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index c344114404..c40795dc3f 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -219,7 +219,7 @@ bind:dirty={dirtyPath} bind:error={pathError} bind:path={newEditedPath} - initialPath={newPath} + initialPath={newApp ? newPath : (savedApp?.path ?? newPath)} namePlaceholder="app" kind="app" autofocus={false} diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index e4e030de67..fe784b9661 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -200,18 +200,19 @@ export type App = { version?: number /** * User-typed path persisted on the autosaved App when it differs from - * the deployed/seeded baseline. The home list renders it so a friendly - * name shows up instead of the autogenerated `u/{user}/draft_{uuid}` - * URL slot for renames-in-progress and brand-new drafts. Dropped from - * the JSON once the typed path matches the baseline again, and deploy - * clears the whole draft. + * the deployed/seeded baseline. The home/review/Drafts lists render it (read + * from `value->>'path'`) so a friendly name shows up instead of the + * autogenerated `u/{user}/draft_{uuid}` storage key for renames-in-progress + * and brand-new drafts. Dropped from the JSON once the typed path matches the + * baseline again, and deploy clears the whole draft. The deployed/original + * path is the URL, not this field. */ - draft_path?: string + path?: string /** * App summary persisted on the autosaved App so a draft round-trips it — the * autosave stores the bare App value, which otherwise drops the summary (it - * normally lives in the `app` table column, set only on deploy). Mirrors - * `draft_path`: draft-only metadata; the deployed summary column is + * normally lives in the `app` table column, set only on deploy). Mirrors the + * `path` field above: draft-only metadata; the deployed summary column is * authoritative, and the Review & Deploy page reads it from the draft and * sends it as the summary on deploy. */ diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 07e7c7bb29..951bc279ca 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -103,8 +103,8 @@ * still toggle the mode after mount; this prop only seeds the * initial state. */ defaultSplitWithPreview?: boolean - /** User-typed path when it differs from `savedApp.path`. The route injects - * it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */ + /** User-typed path when it differs from `savedApp.path`. The route persists + * it as the draft's own `path` so the home row shows the friendly name. */ pendingDraftPath?: string | undefined // Threaded to the AutosaveIndicator's "Reset to deployed" button. onResetToDeployed?: () => void | Promise diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 2ecbc9f369..0f0ba4675a 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -139,7 +139,7 @@ /** Surfaces the user-typed path (`newEditedPath`) up to the route * when (and only when) it differs from the deployed/seeded * `savedApp.path`. The route writes it into the autosaved raw-app - * draft as `draft_path` so the home-page row can render the + * draft as its own `path` so the home-page row can render the * friendly name instead of the URL's autogenerated draft slot. */ pendingDraftPath?: string | undefined // Threaded to the `AutosaveIndicator` popover so its "Reset to diff --git a/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte b/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte index 0716398131..ecbfdb4b45 100644 --- a/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte @@ -62,8 +62,9 @@ const kind = DEPLOY_KIND_BY_DRAFT_KIND[baseKind] ?? baseKind donly[`${kind}/${it.path}`] = it.draft_only // A never-deployed app/raw_app is parked at a synthetic `…/draft_` - // storage path with the user's typed name in `draft_path`; show that - // (matches the home list) while `path` stays the storage key for loading. + // storage path; the backend surfaces the user's typed name as the + // computed `draft_path` field — show that (matches the home list) while + // `path` stays the storage key for loading. // `summary` comes straight from the draft row, so it shows for every kind // up front instead of only after the diff value loads. return { diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte index fb98e05496..eb7b3eb750 100644 --- a/frontend/src/lib/components/sessions/FlowEditorView.svelte +++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte @@ -79,14 +79,19 @@ newFlow: a draft-only flow has a synthesized `savedFlow` (no_deployed=true) but no deployed row, so it must deploy via createFlow — treating it as !newFlow would updateFlow the draft_ path and 404 "Flow not found". - initialPath: a brand-new flow is stored under a `draft_` path with - its intended name in `draft_path`; seed the builder from `draft_path` - (as the full-page editor does) so the Path widget and deploy use the - friendly name rather than creating a flow literally named draft_. --> + initialPath: the Path widget's rename baseline. For a deployed flow + it's the URL/storage path (deployed/original); the draft's intended + name rides in the flow value's own `path`, which FlowBuilder seeds into + the Path widget — so the topbar shows the friendly name while + `initialPath` stays the original. A never-deployed flow (parked at a + `draft_` key) has no original, so seed the baseline from its own + friendly path instead of flagging the storage key as a rename. --> (undefined) // The header collapses both "not yet bound" and "reverted to baseline" to // `undefined`. `surfacedDraftPath` tells them apart: the initial undefined - // (before the header binds) must not clobber the `draft_path` seeded by + // (before the header binds) must not clobber the `typedPath` seeded by // loadRawApp, but a revert/clear after a real typed path must drop the stale - // friendly name — mirroring the script codec's `else delete draft_path`. + // friendly name. let surfacedDraftPath = false $effect(() => { const dp = pendingDraftPath @@ -49,9 +49,9 @@ if (!val) return if (dp !== undefined) { surfacedDraftPath = true - if (val.draft_path !== dp) val.draft_path = dp - } else if (surfacedDraftPath && val.draft_path !== undefined) { - val.draft_path = undefined + if (val.typedPath !== dp) val.typedPath = dp + } else if (surfacedDraftPath && val.typedPath !== undefined) { + val.typedPath = undefined } }) }) @@ -104,7 +104,7 @@ bind:data={runtime.rawApp.val.data} bind:summary={runtime.rawApp.val.summary} bind:pendingDraftPath - newPath={runtime.rawApp.val.draft_path ?? runtime.rawApp.val.path} + newPath={runtime.rawApp.val.typedPath ?? runtime.rawApp.val.path} {path} autosaveWorkspace={workspaceId} autosavePath={path} diff --git a/frontend/src/lib/components/sessions/SessionEditorTarget.svelte b/frontend/src/lib/components/sessions/SessionEditorTarget.svelte index f4c29188f9..55bd697bb9 100644 --- a/frontend/src/lib/components/sessions/SessionEditorTarget.svelte +++ b/frontend/src/lib/components/sessions/SessionEditorTarget.svelte @@ -49,7 +49,7 @@ function buildCodec(): DraftSyncCodec { if (kind === 'flow') return makeFlowCodec(runtime) - if (kind === 'script') return makeScriptCodec(runtime, () => path) + if (kind === 'script') return makeScriptCodec(runtime) return makeRawAppCodec(runtime) } diff --git a/frontend/src/lib/components/sessions/appDraftCodec.test.ts b/frontend/src/lib/components/sessions/appDraftCodec.test.ts index ba0d6f5f2d..56b8372615 100644 --- a/frontend/src/lib/components/sessions/appDraftCodec.test.ts +++ b/frontend/src/lib/components/sessions/appDraftCodec.test.ts @@ -60,21 +60,26 @@ describe('appDraftCodec — custom_path round-trip', () => { }) }) -describe('appDraftCodec — draft_path round-trip', () => { - it('serializes draft_path so a path edit changes the draft (and its sig)', () => { - const draft = runtimeRawAppToDraft(runtime({ draft_path: 'u/admin/friendly' })) - expect(draft.draft_path).toBe('u/admin/friendly') - // The autosave keys on JSON.stringify(draft); without draft_path a rename +describe('appDraftCodec — typed path round-trip', () => { + it('serializes the typed path as the draft `path` so an edit changes the draft (and its sig)', () => { + const draft = runtimeRawAppToDraft(runtime({ typedPath: 'u/admin/friendly' })) + expect(draft.path).toBe('u/admin/friendly') + // The autosave keys on JSON.stringify(draft); without the path a rename // would be invisible and never persist. expect(JSON.stringify(draft)).toContain('u/admin/friendly') }) + it('omits the draft `path` when there is no rename', () => { + const draft = runtimeRawAppToDraft(runtime({ typedPath: undefined })) + expect(draft.path).toBeUndefined() + }) + it('survives a full runtime → draft → runtime round-trip', () => { - const original = runtime({ draft_path: 'u/admin/renamed' }) + const original = runtime({ typedPath: 'u/admin/renamed' }) const back = applyDraftToRuntimeRawApp( - runtime({ draft_path: undefined }), + runtime({ typedPath: undefined }), runtimeRawAppToDraft(original) ) - expect(back.draft_path).toBe('u/admin/renamed') + expect(back.typedPath).toBe('u/admin/renamed') }) }) diff --git a/frontend/src/lib/components/sessions/appDraftCodec.ts b/frontend/src/lib/components/sessions/appDraftCodec.ts index 5f870be92d..342914633e 100644 --- a/frontend/src/lib/components/sessions/appDraftCodec.ts +++ b/frontend/src/lib/components/sessions/appDraftCodec.ts @@ -11,16 +11,18 @@ export type RawAppDraft = { summary: string policy?: any custom_path?: string - // User-typed path while the app is parked at a `…/draft_` storage path. - // Must round-trip through the draft so the home/review/Drafts lists render the - // friendly name (they read `value->>'draft_path'`) — and so editing the path - // in the editor changes the persisted draft and triggers an autosave. - draft_path?: string + // User-typed path while the app is parked at a `…/draft_` storage key. + // Round-trips through the draft as its own `path` so the home/review/Drafts + // lists render the friendly name (they read `value->>'path'`) — and so editing + // the path in the editor changes the persisted draft and triggers an autosave. + // Present only on a rename; the storage key is the URL, not this field. + path?: string } // The shape `runtime.rawApp.val` actually holds (see SessionRuntime in -// sessionRuntime.svelte.ts). Adds `path` (a key, not a draft field) and -// makes `policy` required for the editor's live binding. +// sessionRuntime.svelte.ts). `path` is the storage key (a runtime-only field, +// the session editor target); `typedPath` is the user-typed rename, set only +// when it differs from the deployed path and persisted as the draft's own `path`. export type RuntimeRawApp = { summary: string path: string @@ -29,12 +31,13 @@ export type RuntimeRawApp = { data: RawAppData policy: any custom_path?: string - draft_path?: string + typedPath?: string } -// Strip runtime-only metadata (just `path`, the storage key) when persisting -// to UserDraft. `custom_path` is a real draft field and must round-trip — else -// session sync erases a draft's custom URL. +// Strip runtime-only metadata (the storage-key `path`) when persisting to +// UserDraft, and surface the user-typed rename under the draft's own `path` so +// the backend lists read it from `value->>'path'`. `custom_path` is a real draft +// field and must round-trip — else session sync erases a draft's custom URL. export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft { return { summary: raw.summary, @@ -43,12 +46,13 @@ export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft { data: raw.data, policy: raw.policy, custom_path: raw.custom_path, - draft_path: raw.draft_path + ...(raw.typedPath ? { path: raw.typedPath } : {}) } } // Overlay a UserDraft-stored raw-app draft onto an existing runtime raw app, -// preserving the runtime-only `path` field. +// preserving the runtime-only storage-key `path` and mapping the draft's `path` +// (the typed rename) back onto `typedPath`. export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft): RuntimeRawApp { return { ...raw, @@ -58,6 +62,6 @@ export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft): data: dv.data, policy: dv.policy ?? raw.policy, custom_path: dv.custom_path ?? raw.custom_path, - draft_path: dv.draft_path ?? raw.draft_path + typedPath: dv.path ?? raw.typedPath } } diff --git a/frontend/src/lib/components/sessions/flowDraftSig.test.ts b/frontend/src/lib/components/sessions/flowDraftSig.test.ts index c1b2df51dd..fb3c979ede 100644 --- a/frontend/src/lib/components/sessions/flowDraftSig.test.ts +++ b/frontend/src/lib/components/sessions/flowDraftSig.test.ts @@ -20,14 +20,8 @@ describe('flowDraftSig', () => { ) }) - it('changes when only draft_path changes (path rename triggers a save)', () => { - const base = { value: { modules: [] }, summary: 's', draft_path: 'u/admin/draft_abc' } - const renamed = { ...base, draft_path: 'u/admin/friendly' } - expect(flowDraftSig(base)).not.toBe(flowDraftSig(renamed)) - }) - - it('changes when only path changes', () => { - const base = { value: { modules: [] }, summary: 's', path: 'u/admin/a' } - expect(flowDraftSig(base)).not.toBe(flowDraftSig({ ...base, path: 'u/admin/b' })) + it('changes when only path changes (a rename triggers a save)', () => { + const base = { value: { modules: [] }, summary: 's', path: 'u/admin/draft_abc' } + expect(flowDraftSig(base)).not.toBe(flowDraftSig({ ...base, path: 'u/admin/friendly' })) }) }) diff --git a/frontend/src/lib/components/sessions/flowDraftSig.ts b/frontend/src/lib/components/sessions/flowDraftSig.ts index cd53deedf4..26f4041427 100644 --- a/frontend/src/lib/components/sessions/flowDraftSig.ts +++ b/frontend/src/lib/components/sessions/flowDraftSig.ts @@ -4,24 +4,22 @@ // this signature to skip no-op work. It MUST include every top-level field the // editor can change on its own — `summary`, `description`, and the path — // otherwise a change to only that field produces an identical signature and -// never propagates or persists. The Path widget writes a rename into -// `draft_path` (FlowBuilder mirrors the typed path there while the flow is -// parked at its `…/draft_` storage `path`); without it here the rename -// moves no signature and the draft is never saved. +// never propagates or persists. FlowBuilder mirrors the Path widget's typed +// path into the flow's own `path` (the flow is parked at its `…/draft_` +// storage key); without `path` here a rename moves no signature and the draft +// is never saved. export function flowDraftSig(x: { value?: unknown schema?: unknown summary?: unknown description?: unknown path?: unknown - draft_path?: unknown }): string { return JSON.stringify({ value: x.value, schema: x.schema, summary: x.summary, description: x.description, - path: x.path, - draft_path: x.draft_path + path: x.path }) } diff --git a/frontend/src/lib/components/sessions/sessionDraftCodecs.test.ts b/frontend/src/lib/components/sessions/sessionDraftCodecs.test.ts index fd20c4e2dc..875f2fbb97 100644 --- a/frontend/src/lib/components/sessions/sessionDraftCodecs.test.ts +++ b/frontend/src/lib/components/sessions/sessionDraftCodecs.test.ts @@ -16,31 +16,18 @@ function runtimeWith(script: Partial & { path: string }): SessionRunt const STORAGE = 'u/admin/draft_abc' -describe('makeScriptCodec — draft_path (path rename)', () => { - it('writes draft_path when the typed path differs from the storage key', () => { +describe('makeScriptCodec — path rename', () => { + it('carries the typed path on the draft so the lists show the friendly name', () => { const codec = makeScriptCodec( - runtimeWith({ path: 'u/admin/friendly', content: 'c', summary: 's' }), - () => STORAGE + runtimeWith({ path: 'u/admin/friendly', content: 'c', summary: 's' }) ) - const draft = codec.storeToDraft(undefined) as (NewScript & { draft_path?: string }) | undefined - expect(draft?.draft_path).toBe('u/admin/friendly') - }) - - it('drops draft_path when the typed path equals the storage key', () => { - const codec = makeScriptCodec( - runtimeWith({ path: STORAGE, content: 'c', summary: 's' }), - () => STORAGE - ) - const draft = codec.storeToDraft(undefined) as (NewScript & { draft_path?: string }) | undefined - expect(draft?.draft_path).toBeUndefined() + const draft = codec.storeToDraft(undefined) + expect(draft?.path).toBe('u/admin/friendly') }) it('signature changes on a rename, so the outbound sync persists it', () => { - const before = makeScriptCodec(runtimeWith({ path: STORAGE, content: 'c' }), () => STORAGE) - const after = makeScriptCodec( - runtimeWith({ path: 'u/admin/renamed', content: 'c' }), - () => STORAGE - ) + const before = makeScriptCodec(runtimeWith({ path: STORAGE, content: 'c' })) + const after = makeScriptCodec(runtimeWith({ path: 'u/admin/renamed', content: 'c' })) expect(before.sig(before.storeToDraft(undefined)!)).not.toBe( after.sig(after.storeToDraft(undefined)!) ) diff --git a/frontend/src/lib/components/sessions/sessionDraftCodecs.ts b/frontend/src/lib/components/sessions/sessionDraftCodecs.ts index 9222438534..a60e8a16b4 100644 --- a/frontend/src/lib/components/sessions/sessionDraftCodecs.ts +++ b/frontend/src/lib/components/sessions/sessionDraftCodecs.ts @@ -35,31 +35,25 @@ export function makeFlowCodec(runtime: SessionRuntime): DraftSyncCodec { } } -// `NewScript` has no `draft_path` of its own; the session editor parks a rename -// there so the home/Drafts lists (which read `draft_path`) show the typed name. -type ScriptDraft = NewScript & { draft_path?: string } +type ScriptDraft = NewScript -export function makeScriptCodec( - runtime: SessionRuntime, - // The draft's storage key (the URL path). A never-deployed script is parked - // here at `…/draft_` while the user's typed name lives in `script.path`. - storagePath: () => string -): DraftSyncCodec { +export function makeScriptCodec(runtime: SessionRuntime): DraftSyncCodec { return { itemKind: 'script', // Must include every field write_script can set — not just content — // else a summary-only/language-only change yields an identical signature // and the inbound/outbound sync skips it (the chat's change is then // invisible in the open editor and clobbered by the next content save). - // `draft_path` carries a rename: ScriptBuilder binds the Path widget - // straight to `script.path` (no separate draft field like flow/raw_app), - // so without it here a rename moves no signature and never autosaves. + // `path` carries a rename: ScriptBuilder binds the Path widget straight to + // `script.path`, which IS the draft's path (the backend lists read it from + // `value->>'path'`), so without it here a rename moves no signature and + // never autosaves. sig: (d) => JSON.stringify({ content: d.content ?? '', summary: d.summary, language: d.language, - draft_path: d.draft_path + path: d.path }), debounceMs: DEBOUNCE_MS, applyDraftToStore(incoming) { @@ -74,17 +68,10 @@ export function makeScriptCodec( const script = runtime.scriptStore.val if (!script) return undefined // Merge over the existing entry so fields the preview doesn't edit - // (set by the chat) survive a content-only save. - const merged: ScriptDraft = { ...(current ?? script), ...script } - // Surface a rename to the home/Drafts lists, which read `draft_path` - // (the typed `script.path` is the draft *value*'s path, not its storage - // key). Mirror flow/raw_app: set it only when the typed path differs - // from the storage key, and drop it once it matches again so a revert - // doesn't leave a stale friendly name behind. - const typed = script.path - if (typed && typed !== storagePath()) merged.draft_path = typed - else delete merged.draft_path - return merged + // (set by the chat) survive a content-only save. `script.path` (the + // user-typed name) rides along, so a rename persists and the home/Drafts + // lists show the friendly name. + return { ...(current ?? script), ...script } } } } diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index a575a19ab8..bbf7047121 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -105,7 +105,7 @@ export interface SessionRuntime { summary: string path: string custom_path?: string - draft_path?: string + typedPath?: string } | undefined } @@ -451,15 +451,11 @@ function createRuntime(session: Session): SessionRuntime { ) ) as NewScript) : { - // Seed from the draft's own path (a rename lives in `draft_path`, - // else `path`), not the storage key. Otherwise re-seeding a renamed + // Seed from the draft's own `path` (the Path widget binds it + // directly), not the storage key. Otherwise re-seeding a renamed // never-deployed draft (e.g. a script→script switch re-runs loadScript - // with the draft still in memory) resets the path to `draft_`, - // and the next autosave drops `draft_path` — clobbering the rename. - path: - (aiDraft as NewScript & { draft_path?: string }).draft_path ?? - aiDraft.path ?? - path, + // with the draft still in memory) resets the path to the storage key. + path: aiDraft.path ?? path, summary: aiDraft.summary ?? '', content: '', description: '', @@ -610,7 +606,7 @@ function createRuntime(session: Session): SessionRuntime { summary: draftValue?.summary ?? result.summary ?? '', path: result.path, custom_path: draftValue?.custom_path ?? result.custom_path, - draft_path: draftValue?.draft_path + typedPath: draftValue?.path } // Seed the per-tab last_sync from the server draft's timestamp so // later saves attach a matching last_sync and the server can reject diff --git a/frontend/src/lib/rawAppDeploy.ts b/frontend/src/lib/rawAppDeploy.ts index 3439cb6b6d..b85370b7dc 100644 --- a/frontend/src/lib/rawAppDeploy.ts +++ b/frontend/src/lib/rawAppDeploy.ts @@ -34,10 +34,10 @@ export async function deployRawAppDraft( const app = await AppService.getAppByPath({ workspace, path, getDraft: true, rawApp: true }) const draft = (app as any).draft // Deploy at the draft's intended path. A raw-app draft carries the user-typed - // path in `draft_path` (a never-deployed app is parked at a synthetic - // `u/{user}/draft_{uuid}` storage key); the URL `path` below stays that storage + // path in its own `path` (a never-deployed app is parked at a synthetic + // `u/{user}/draft_{uuid}` storage key); the URL `path` arg stays that storage // key. Falls back to `path` for an unrenamed draft on a deployed app. - const targetPath = draft?.draft_path ?? draft?.path ?? path + const targetPath = draft?.path ?? path const value = appSourceToDraftValue(draft ?? app, app) const policy = (await updateRawAppPolicy( diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index 6781818bb5..79203acfac 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -305,11 +305,10 @@ export async function deployDraft( const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any const d = r.draft ?? r const requestBody = { - // Deploy at the draft's intended path: flow/app/raw-app drafts keep the - // user-typed path in `draft_path` (a never-deployed item is parked at a - // synthetic `u/{user}/draft_{uuid}` storage key). The URL `path` stays - // that storage key. - path: d.draft_path ?? d.path ?? path, + // Deploy at the draft's intended path: every draft keeps the user-typed + // path in its own `path` (a never-deployed item is parked at a synthetic + // `u/{user}/draft_{uuid}` storage key, the URL `path` arg). + path: d.path ?? path, summary: d.summary ?? '', description: d.description ?? '', value: d.value, @@ -330,24 +329,19 @@ export async function deployDraft( await FlowService.updateFlow({ workspace, path, requestBody }) } // Then deploy any draft trigger edits, so they aren't dropped with the draft. - await deployDraftTriggers( - d.draft_triggers, - workspace, - d.draft_path ?? d.path ?? path, - draftOnly - ) + await deployDraftTriggers(d.draft_triggers, workspace, d.path ?? path, draftOnly) } else if (kind === 'app') { // `raw_app` is handled above; only visual apps reach here. const r = (await AppService.getAppByPath({ workspace, path, getDraft: true })) as any // A visual-app draft is stored as the *bare* app value (grid/theme/..., - // plus a `draft_path` when the path was renamed) — NOT wrapped in - // { value, summary, policy } like script/flow drafts. So the deploy value - // is the draft object itself; fall back to the deployed value when there's - // no draft. `draft_path` and `summary` are draft-only fields mirrored onto - // the App value (the editor drops them on deploy), so strip them from the - // value and apply them as the deploy path / summary column. + // plus its own `path` when renamed) — NOT wrapped in { value, summary, + // policy } like script/flow drafts. So the deploy value is the draft object + // itself; fall back to the deployed value when there's no draft. `path` and + // `summary` are draft-only fields mirrored onto the App value (the editor + // drops them on deploy), so strip them from the value and apply them as the + // deploy path / summary column. const draft = r.draft as Record | undefined - const { draft_path: draftPath, summary: draftSummary, ...appValue } = draft ?? r.value ?? {} + const { path: draftValuePath, summary: draftSummary, ...appValue } = draft ?? r.value ?? {} // Policy isn't carried in the app draft, so it comes from the deployed app // (or a default). custom_path requires admin on update; non-admins send // undefined so the backend preserves the existing route. The draft has no @@ -357,9 +351,9 @@ export async function deployDraft( value: appValue, summary: draftSummary ?? r.summary ?? '', policy: r.policy ?? { execution_mode: 'publisher' }, - // Honor the draft's intended path; `draft_path` holds the user-typed path - // for a never-deployed app parked at a `u/{user}/draft_{uuid}` storage key. - path: draftPath ?? r.path ?? path, + // Honor the draft's intended path; the draft's own `path` holds the + // user-typed path for a never-deployed app parked at a synthetic storage key. + path: draftValuePath ?? r.path ?? path, custom_path: isAdmin ? (r.custom_path ?? '') : undefined } // Same as flows: draft-only apps have no app row → create; diff --git a/frontend/src/lib/workspaceDrafts.svelte.ts b/frontend/src/lib/workspaceDrafts.svelte.ts index 90a7cf9542..34e3e43d4c 100644 --- a/frontend/src/lib/workspaceDrafts.svelte.ts +++ b/frontend/src/lib/workspaceDrafts.svelte.ts @@ -23,9 +23,9 @@ export interface DraftItem { kind: DraftKind path: string summary?: string - /** User-typed friendly path (from the draft JSON's `draft_path`) when it - * differs from the storage `path` — e.g. a never-deployed item parked at - * `u/{user}/draft_{uuid}`. Display this instead of `path` when present. */ + /** User-typed friendly path (computed by the backend from the draft JSON's + * own `path`) when it differs from the storage `path` — e.g. a never-deployed + * item parked at `u/{user}/draft_{uuid}`. Display this instead of `path`. */ draft_path?: string /** Never deployed — exists only as a draft. */ draft_only: boolean diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index 1e6f94180f..5261929aa6 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -429,7 +429,7 @@ summary={app.summary} app={app.value} {deployedBaseline} - newPath={app.value?.draft_path ?? app.path} + newPath={app.value?.path ?? app.path} path={page.params.path ?? ''} policy={app.policy} bind:savedApp diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 91071f9434..d41433cb88 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -42,9 +42,9 @@ summary: string policy?: any custom_path?: string - /** User-typed path the home list renders, set only when it differs - * from the deployed/seeded `savedApp.path`. */ - draft_path?: string + /** User-typed path the home list renders (read from `value->>'path'`), set + * only when it differs from the deployed/seeded `savedApp.path`. */ + path?: string } let files: Record | undefined = $state(undefined) @@ -56,7 +56,7 @@ let policy: any = $state({}) let summary = $state('') /** User-typed path from `RawAppEditorHeader` when it differs from - * `savedApp.path`; mirrored into the draft below as `draft_path` for the + * `savedApp.path`; mirrored into the draft below as its own `path` for the * home list's friendly name. */ let pendingDraftPath = $state(undefined) @@ -113,12 +113,12 @@ summary, policy, custom_path: savedApp?.custom_path, - // Persist the typed path as `draft_path` only when it actually differs - // from the current path — a `draft_path` equal to the baseline is a + // Persist the typed path as the draft's own `path` only when it actually + // differs from the current path — a `path` equal to the baseline is a // no-op that would block the draft from deduping against the deployed // app (which carries none). Drops back out on a revert or deploy. ...(pendingDraftPath && pendingDraftPath !== (savedApp?.path ?? '') - ? { draft_path: pendingDraftPath } + ? { path: pendingDraftPath } : {}) } as RawAppDraft }) @@ -135,9 +135,9 @@ summary = app.summary // lastVersion = app.version policy = app.policy - // Prefer the saved `draft_path` so the topbar shows the pending name, not - // the `draft_{uuid}` URL. See /flows/edit's loader. - newPath = (app as any).draft_path ?? app.path + // Deployed/URL path; the loader overrides this with the draft's own typed + // `path` afterwards so the topbar shows the pending name when renamed. + newPath = app.path } /** Increments per `loadApp` call. Stale loads (e.g. when picker @@ -273,7 +273,7 @@ deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined) // Deployed baseline for the autosave `discardIf`, captured BEFORE the swap // below mutates `backendApp`. Mirrors the bundle `$effect`'s shape (minus - // the edit-only `draft_path`) so an unedited draft compares equal. + // the edit-only `path`) so an unedited draft compares equal. // `undefined` when there's no deployed row. deployedBaseline = backendApp.no_deployed ? undefined @@ -300,14 +300,9 @@ summary?: string policy?: any custom_path?: string - draft_path?: string + path?: string } | undefined - // Surface the saved `draft_path` on `backendApp` so `extractRawApp` seeds - // `newPath` with the friendly name, not the `draft_{uuid}` URL. - if (savedRawAppDraft?.draft_path) { - ;(backendApp as any).draft_path = savedRawAppDraft.draft_path - } if (backendApp.no_deployed) { backendApp.value = { files: savedRawAppDraft?.files ?? {}, @@ -344,6 +339,10 @@ // $effect re-mirrors them into `draftSync.draft`; the first write is // swallowed by `acquireEntry`'s seed guard, so no POST. extractRawApp(backendApp) + // The draft's own typed `path` (set only on a rename) is the friendly name + // the topbar shows; `extractRawApp` seeded `newPath` from the deployed/URL + // path, so override it here. The deployed/original path stays the URL. + if (savedRawAppDraft?.path) newPath = savedRawAppDraft.path // "Load another user's draft" handoff: their value is a flat RawAppDraft // bundle. Override the local pieces with it; overlay mode (we have our own // draft) hard-locks saves until the user confirms overwriting. The bundle @@ -363,7 +362,7 @@ data = v.data ?? { ...DEFAULT_DATA } summary = v.summary ?? '' policy = v.policy ?? {} - newPath = v.draft_path ?? savedApp?.path ?? path + newPath = v.path ?? savedApp?.path ?? path if (hasOwnDraft) { OtherUserDraftLoad.beginOverlay({ workspace: $workspaceStore!, @@ -379,7 +378,7 @@ summary, policy, custom_path: savedApp?.custom_path, - ...(pendingDraftPath ? { draft_path: pendingDraftPath } : {}) + ...(pendingDraftPath ? { path: pendingDraftPath } : {}) } as RawAppDraft, onResetToOwnDraft: () => loadApp({ getDraft: true }) }) 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 e036504b9e..988e0faa19 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -150,7 +150,7 @@ deployedBaseline = undefined // Suspend autosave around the bootstrap cascade: the Path widget's // `initPath → reset → bind:path` chain seeds a friendly auto-name that - // FlowBuilder mirrors into `flow.draft_path` — a programmatic write that + // FlowBuilder mirrors into `flow.path` — a programmatic write that // must not post as the user's first save. Resume on first interaction // (with a 5s fallback) rather than guessing the cascade's length. if ($workspaceStore) { @@ -349,16 +349,26 @@ ? ({ ...deployedFlow, ...draftFromBackend } as Flow) : (deployedFlow as Flow) savedFlow = structuredClone($state.snapshot(effectiveFlow)) as Flow + // `savedFlow` is the deployed baseline (diff "deployed" side + restore + // target), so its path must stay the deployed path even when the draft + // overlay renamed `effectiveFlow.path`. + if (!backendFlow.no_deployed) savedFlow.path = (deployedFlow as Flow).path // Baseline for the autosave `discardIf`: the deployed flow WITHOUT the // draft overlay (matches the unedited seed when no draft exists). 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. - const renderedDraftPath = (effectiveFlow as any).draft_path as string | undefined - if (renderedDraftPath) flowInitialPath = renderedDraftPath + // `flowInitialPath` is the Path widget's rename baseline and the + // `updateFlow` target on deploy. For a deployed flow it stays the URL + // (deployed/original) path, while the draft's renamed path rides in + // `effectiveFlow.path` (seeded into the Path widget via FlowBuilder's + // `$pathStore`) — so the topbar shows the pending name without conflating + // it with the original. A never-deployed draft (parked at a `draft_{uuid}` + // storage key) has no original, so seed the baseline from its own friendly + // path: else the storage key would be flagged as a rename target. + if (backendFlow.no_deployed) { + flowInitialPath = (effectiveFlow.path as string) || flowInitialPath + } // "Load another user's draft" handoff: render their value over the deployed // metadata. Overlay mode (we have our own draft) never saves until the user