From 420beeadd2ef66d79aa3eb39447632cd6fae51dd Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 00:35:37 +0200 Subject: [PATCH] fix(sessions): leave a hosted editor whose item a direct discard removed, and follow a renamed save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline draft banner's Discard calls UserDraft.discard straight off, not the discard_local_draft tool, so nothing told the tab about it. For a draft-only item the draft IS the item: discarding deletes its only representation and the tab kept editing a synthesized stand-in for something that no longer exists. Each editor now reports whether the item survived, and the session views hand that back to the tab, which returns to the list it was opened from. A draft-only schedule also opens with its path editable — saving CREATEs — so the save can land somewhere other than where the tab points. The schedule host discarded onUpdate's saved path and only remounted, re-opening the old path; it now retargets the tab like the resource and variable hosts do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- frontend/src/lib/components/ResourceEditor.svelte | 9 +++++++-- .../lib/components/ResourceEditorDrawer.svelte | 13 ++++++++++--- frontend/src/lib/components/VariableEditor.svelte | 10 +++++++++- .../lib/components/sessions/PreviewTabHost.svelte | 7 ++++++- .../components/sessions/ResourceEditorView.svelte | 1 + .../components/sessions/ScheduleEditorView.svelte | 15 +++++++++++++-- .../components/sessions/VariableEditorView.svelte | 1 + .../triggers/schedules/ScheduleEditorInner.svelte | 15 +++++++++++++-- 8 files changed, 60 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 89acac79de..ddea758bf4 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -290,11 +290,16 @@ export function localDraftCurrent(): ResourceState | undefined { return current } - export function discardLocalDraft(): void { - if (!selected) return + /** Returns whether the resource still exists afterwards. Discarding a draft + * that was never deployed removes the resource itself — `initialStates` holds a + * synthesized stand-in, not a baseline to fall back to — so a caller showing it + * has to stop rather than keep displaying that stand-in. */ + export function discardLocalDraft(): boolean { + if (!selected) return true UserDraft.discard('resource', initialPath ?? '', initialStates[selected], { workspace: selected }) + return !!existedInitially[selected] } $effect(() => { diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 795a413cbd..5d444bede6 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -25,7 +25,8 @@ onRestored = undefined, onSaved = undefined, useDrawer = true, - onBack = undefined + onBack = undefined, + onRemoved = undefined }: { workspace?: string disableChatOffset?: boolean @@ -44,6 +45,9 @@ /** Inline only: offered in the header when the host replaced something the * user should be able to get back to (a session tab that took over the list). */ onBack?: () => void + /** The resource is gone — a draft-only one whose draft was discarded. A host + * addressing it by path (a session tab) has to stop showing it. */ + onRemoved?: () => void } = $props() let drawer: Drawer | undefined = $state() @@ -61,7 +65,8 @@ save: () => Promise localDraftDeployed: () => unknown localDraftCurrent: () => unknown - discardLocalDraft: () => void + /** False when the discard removed the resource (it was draft-only). */ + discardLocalDraft: () => boolean } | undefined = $state(undefined) let hasLocalDraft = $state(false) @@ -171,7 +176,9 @@ reserveSpace={mode == 'edit'} getDeployed={() => resourceEditor?.localDraftDeployed()} getCurrent={() => resourceEditor?.localDraftCurrent()} - onDiscard={() => resourceEditor?.discardLocalDraft()} + onDiscard={() => { + if (resourceEditor?.discardLocalDraft() === false) onRemoved?.() + }} disabled={!canWriteSelected} /> {/snippet} diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 8b0d78800b..6d67e0c0fd 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -43,7 +43,8 @@ workspace = undefined, useDrawer = true, onBack = undefined, - onSaved = undefined + onSaved = undefined, + onRemoved = undefined }: { workspace?: string /** @@ -59,6 +60,9 @@ /** Fires after a save, with the path it wrote to — which is not the one it was * opened on when the user renamed it. */ onSaved?: (savedPath?: string) => void + /** The variable is gone — a draft-only one whose draft was discarded. A host + * addressing it by path (a session tab) has to stop showing it. */ + onRemoved?: () => void } = $props() let curWs = $derived(workspace ?? $workspaceStore) @@ -343,6 +347,10 @@ UserDraft.discard('variable', editPath ?? '', initialStates[selected], { workspace: selected }) + // A draft-only variable has no deployed row under the draft, so discarding + // it removed the variable: `initialStates` holds a synthesized stand-in, + // not a baseline to fall back to. + if (!existedInitially[selected]) onRemoved?.() }} disabled={!can_write} /> diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index a44f64de1b..3fae60312e 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -373,7 +373,12 @@ {#await import('./ScheduleEditorView.svelte')} {@render editorLoading()} {:then Module} - + {/await} {:else if slot.entityKind === 'resource'} {#await import('./ResourceEditorView.svelte')} diff --git a/frontend/src/lib/components/sessions/ResourceEditorView.svelte b/frontend/src/lib/components/sessions/ResourceEditorView.svelte index 12c5720766..1706d226ce 100644 --- a/frontend/src/lib/components/sessions/ResourceEditorView.svelte +++ b/frontend/src/lib/components/sessions/ResourceEditorView.svelte @@ -38,6 +38,7 @@ useDrawer={false} workspace={workspaceId} {onBack} + onRemoved={onBack} onSaved={(saved) => { if (saved && saved !== path) onRenamed?.(saved) }} diff --git a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte index f221d63ec0..a1c99b02da 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -8,7 +8,8 @@ let { path, workspaceId, - onBack + onBack, + onRenamed }: { /** The schedule this tab edits (the row its location deep-links). */ path: string @@ -17,6 +18,10 @@ workspaceId: string /** Back to the list this editor was reached through; the tab replaced it. */ onBack?: () => void + /** The item was saved under a different path. The tab addresses it by path — + * as do its label, the chat's ACTIVE PREVIEW and the draft key — so the tab + * has to follow, or all four keep naming an item that no longer exists. */ + onRenamed?: (newPath: string) => void } = $props() // Captured at init, so it must read the current prop rather than close over it. @@ -59,7 +64,13 @@ bind:this={editor} useDrawer={false} showDraftBanner - onUpdate={() => generation++} + onRemoved={onBack} + onUpdate={(saved: string | undefined) => { + generation++ + // A draft-only schedule opens with its path editable (saving CREATEs), + // so the save can land somewhere other than where the tab is pointed. + if (saved && saved !== path) onRenamed?.(saved) + }} > {#snippet customLabel()}
diff --git a/frontend/src/lib/components/sessions/VariableEditorView.svelte b/frontend/src/lib/components/sessions/VariableEditorView.svelte index b6397cecc6..4c5dce93da 100644 --- a/frontend/src/lib/components/sessions/VariableEditorView.svelte +++ b/frontend/src/lib/components/sessions/VariableEditorView.svelte @@ -38,6 +38,7 @@ useDrawer={false} workspace={workspaceId} {onBack} + onRemoved={onBack} onSaved={(saved) => { if (saved && saved !== path) onRenamed?.(saved) }} diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 1aac6d1076..9b9b2ee63f 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -68,7 +68,10 @@ // false) it is the host's, since the trigger panel inside a script or flow // editor is covered by that editor's banner. A host that stands alone — a // session's schedule tab — opts in, or nothing says an edit is unsaved. - showDraftBanner = false + showDraftBanner = false, + // The schedule is gone — a draft-only one whose draft the banner discarded. + // A host addressing it by path (a session tab) has to stop showing it. + onRemoved = undefined } = $props() let optionTabSelected: @@ -79,6 +82,9 @@ | 'dynamic_skip' = $state('error_handler') let initialPath = $state('') let edit = $state(true) + // Opened on a schedule that exists only as a draft: its draft IS the schedule, + // so discarding it removes the item rather than reverting it. + let draftOnly = $state(false) let schedule: string = $state('0 0 12 * *') let cronVersion: string = $state('v2') let isLatestCron = $state(true) @@ -183,6 +189,7 @@ const { overlay: draftOverlay, noDeployed } = await loadSchedule(defaultCfg) // Draft-only schedules have no deployed row, so saving must CREATE (update 404s). edit = !noDeployed + draftOnly = noDeployed if (!defaultCfg) { // Form holds DEPLOYED here; capture it as `initialConfig` so the // dirty check / banner fires whenever a saved draft exists. @@ -339,6 +346,7 @@ drawer?.openDrawer() runnable = undefined edit = false + draftOnly = false // No deployed baseline for a brand-new schedule. The editor instance // is reused across open() calls, so clear any baseline left by a prior // openEdit — otherwise the "unsaved changes" banner / dirty check would @@ -1455,7 +1463,10 @@ getDeployed={() => draftSync.deployed} reserveSpace={draftSync.hasBaseline} getCurrent={() => draftSync.current} - onDiscard={() => draftSync.resetToDeployed(initialPath)} + onDiscard={async () => { + await draftSync.resetToDeployed(initialPath) + if (draftOnly) onRemoved?.() + }} disabled={!can_write} /> {/if}