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}