fix(sessions): leave a hosted editor whose item a direct discard removed, and follow a renamed save

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
Diego Imbert
2026-09-06 00:35:37 +02:00
co-authored by Claude Opus 5
parent 947fd6b1cb
commit 420beeadd2
8 changed files with 60 additions and 11 deletions
@@ -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(() => {
@@ -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<boolean>
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}
@@ -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}
/>
@@ -373,7 +373,12 @@
{#await import('./ScheduleEditorView.svelte')}
{@render editorLoading()}
{:then Module}
<Module.default path={slot.path} {workspaceId} onBack={backToList} />
<Module.default
path={slot.path}
{workspaceId}
onBack={backToList}
onRenamed={retargetTo}
/>
{/await}
{:else if slot.entityKind === 'resource'}
{#await import('./ResourceEditorView.svelte')}
@@ -38,6 +38,7 @@
useDrawer={false}
workspace={workspaceId}
{onBack}
onRemoved={onBack}
onSaved={(saved) => {
if (saved && saved !== path) onRenamed?.(saved)
}}
@@ -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()}
<div class="flex flex-row items-center gap-2 min-w-0">
@@ -38,6 +38,7 @@
useDrawer={false}
workspace={workspaceId}
{onBack}
onRemoved={onBack}
onSaved={(saved) => {
if (saved && saved !== path) onRenamed?.(saved)
}}
@@ -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}