From 3f8485060dd3e0f87f42e794c3e20a3b2a918bd9 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 01:09:15 +0200 Subject: [PATCH] fix(sessions): send the removed item's own tab back to the list, not the active one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A discard resolves asynchronously — the schedule's awaits a fetch of the runnable — so by the time it lands the user may be looking at another tab. Routing removal through `onBack` moved whichever tab was active, taking that one to the list while the tab whose item was gone stayed on it. Removal now retargets by tab id, as the deletion and rename paths already do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../lib/components/sessions/PreviewTabHost.svelte | 13 +++++++++++++ .../components/sessions/ResourceEditorView.svelte | 7 ++++++- .../components/sessions/ScheduleEditorView.svelte | 7 ++++++- .../components/sessions/VariableEditorView.svelte | 7 ++++++- frontend/src/lib/userDraft.svelte.ts | 8 +++----- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 3fae60312e..5317ba5fb8 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -179,6 +179,16 @@ : undefined ) + // The item is gone (a draft-only one whose draft was discarded), so the tab has + // the same destination as `backToList` — but bound to this tab by id, not to + // whichever is active: a discard completes asynchronously, and by then the user + // may be looking at another tab, which must not be the one sent to the list. + const returnToList = $derived( + slot.kind === 'entity' && runtime + ? () => runtime.previewTabs.retargetTabTo(tab.id, entityListHref(whereIs(tab))) + : undefined + ) + // Follow a rename: the editor stays mounted and keeps editing the item, but the // tab, its label, the chat's ACTIVE PREVIEW and the draft key all address it by // path — so they have to move with it, or they name an item that no longer exists. @@ -377,6 +387,7 @@ path={slot.path} {workspaceId} onBack={backToList} + onRemoved={returnToList} onRenamed={retargetTo} /> {/await} @@ -388,6 +399,7 @@ path={slot.path} {workspaceId} onBack={backToList} + onRemoved={returnToList} onRenamed={retargetTo} /> {/await} @@ -399,6 +411,7 @@ path={slot.path} {workspaceId} onBack={backToList} + onRemoved={returnToList} onRenamed={retargetTo} /> {/await} diff --git a/frontend/src/lib/components/sessions/ResourceEditorView.svelte b/frontend/src/lib/components/sessions/ResourceEditorView.svelte index 1706d226ce..e6a7f3204b 100644 --- a/frontend/src/lib/components/sessions/ResourceEditorView.svelte +++ b/frontend/src/lib/components/sessions/ResourceEditorView.svelte @@ -6,6 +6,7 @@ path, workspaceId, onBack, + onRemoved, onRenamed }: { /** The resource this tab edits (the row its location deep-links). */ @@ -15,6 +16,10 @@ workspaceId: string /** Back to the list this editor was reached through; the tab replaced it. */ onBack?: () => void + /** The item is gone — a draft-only one whose draft was discarded. Distinct from + * `onBack`, which moves whichever tab is active: a discard can complete after + * the user has moved on, and only this tab is the one to send back. */ + onRemoved?: () => 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. */ @@ -38,7 +43,7 @@ useDrawer={false} workspace={workspaceId} {onBack} - onRemoved={onBack} + {onRemoved} 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 a1c99b02da..b2fadb1f46 100644 --- a/frontend/src/lib/components/sessions/ScheduleEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScheduleEditorView.svelte @@ -9,6 +9,7 @@ path, workspaceId, onBack, + onRemoved, onRenamed }: { /** The schedule this tab edits (the row its location deep-links). */ @@ -18,6 +19,10 @@ workspaceId: string /** Back to the list this editor was reached through; the tab replaced it. */ onBack?: () => void + /** The item is gone — a draft-only one whose draft was discarded. Distinct from + * `onBack`, which moves whichever tab is active: the discard awaits a reload of + * the runnable, by which time the user may be looking at another tab. */ + onRemoved?: () => 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. */ @@ -64,7 +69,7 @@ bind:this={editor} useDrawer={false} showDraftBanner - onRemoved={onBack} + {onRemoved} onUpdate={(saved: string | undefined) => { generation++ // A draft-only schedule opens with its path editable (saving CREATEs), diff --git a/frontend/src/lib/components/sessions/VariableEditorView.svelte b/frontend/src/lib/components/sessions/VariableEditorView.svelte index 4c5dce93da..13edc6e467 100644 --- a/frontend/src/lib/components/sessions/VariableEditorView.svelte +++ b/frontend/src/lib/components/sessions/VariableEditorView.svelte @@ -6,6 +6,7 @@ path, workspaceId, onBack, + onRemoved, onRenamed }: { /** The variable this tab edits (the row its location deep-links). */ @@ -15,6 +16,10 @@ workspaceId: string /** Back to the list this editor was reached through; the tab replaced it. */ onBack?: () => void + /** The item is gone — a draft-only one whose draft was discarded. Distinct from + * `onBack`, which moves whichever tab is active: a discard can complete after + * the user has moved on, and only this tab is the one to send back. */ + onRemoved?: () => 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. */ @@ -38,7 +43,7 @@ useDrawer={false} workspace={workspaceId} {onBack} - onRemoved={onBack} + {onRemoved} onSaved={(saved) => { if (saved && saved !== path) onRenamed?.(saved) }} diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index aff6e3c932..1fc0aafaac 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -130,11 +130,9 @@ const seedMisses = new Set() // Cells whose last discard removed the item itself (see `takeDraftOnlyDiscard`). const draftOnlyDiscards = new Set() -// Both sets above are read by the tool-completion listener right after the write -// that wrote them — but only while something is listening, and nothing is when the -// user is not in a session. Capped so an unread marker cannot accumulate: the -// oldest is dropped, since a marker is only ever meaningful to the action that -// immediately follows it. +// Nothing reads either set outside a session, so unread markers would accumulate. +// Capped by dropping the oldest: a marker means something only to the action that +// immediately follows the write that left it. const MAX_WRITE_MARKERS = 64 function noteMarker(set: Set, key: string): void { set.add(key)