From 345f40affb5c3f33c8c4c964ad01b1340b426f2d Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 06:18:45 +0200 Subject: [PATCH] fix(sessions): move every tab on an item the editor renamed or removed, not just its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warm sessions share one draft cell and one server row per item, so a rename or a draft-only discard made in one left the others editing a path that is gone. The editor's report now goes to the page, which re-points every tab on that item in that workspace — the same fan-out the chat tools already use. Matching on the item is also what the host's own staleness check was for: a report that lands after its tab has been re-pointed simply finds nothing to match, so `stillShowing` goes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../components/sessions/PreviewTabHost.svelte | 58 ++++++++----------- frontend/src/lib/userDraftDbSyncer.svelte.ts | 18 +++--- .../(root)/(logged)/sessions/+page.svelte | 30 ++++++++++ 3 files changed, 64 insertions(+), 42 deletions(-) diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 2005e49e82..9216baeff6 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -12,7 +12,6 @@ import type { SessionRuntime } from './sessionRuntime.svelte' import { Loader2 } from 'lucide-svelte' import { - entityEditorHref, entityListHref, entityListPage, resolvePreviewTab, @@ -37,7 +36,8 @@ darkMode, fullscreen = false, onNavigate, - onLoad + onLoad, + onEntityMoved }: { tab: SessionPreviewTab session: Session | undefined @@ -63,6 +63,14 @@ onNavigate: (item: WorkspaceItem) => void /** Iframe finished loading — the page reads back its observed location. */ onLoad: (frame: HTMLIFrameElement) => void + /** A hosted entity editor moved its item: renamed it (`to`) or removed it. + * The page re-points every tab on that item, across warm sessions. */ + onEntityMoved: (ev: { + kind: EntityEditorKind + path: string + workspace: string + to?: string + }) => void } = $props() // Editor vs iframe is decided purely from the tab URL (see resolvePreviewTab): @@ -180,42 +188,26 @@ : undefined ) - // An editor reports a removal or a rename after the write it awaited, by which - // time the tab may hold something else entirely. The report is about the item it - // started on, so acting on anything else re-points an unrelated editor — and - // builds the destination out of its location. - function stillShowing(kind: EntityEditorKind, path: string, ws: string): boolean { - const now = resolvePreviewTab(tab.url) - return ( - now.kind === 'entity' && - now.entityKind === kind && - now.path === path && - ws === workspaceId - ) - } - - // 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: by the time a discard lands the user may be looking at - // another tab, which must not be the one sent to the list. + // An editor reports a removal or a rename about the item it started on, after + // the write it awaited — by which time this tab may hold something else, and + // other warm sessions may hold that same item. Both are the page's to resolve: + // it re-points every tab still on the item, in the workspace it was written in, + // which is also the only way the reporting tab is spared a report gone stale. + // A removal has no destination path; a rename carries the one it moved to. const returnToList = $derived( - slot.kind === 'entity' && runtime - ? (kind: EntityEditorKind, fromPath: string, fromWs: string) => { - if (!stillShowing(kind, fromPath, fromWs)) return - runtime.previewTabs.retargetTabTo(tab.id, entityListHref(whereIs(tab))) - } + slot.kind === 'entity' + ? (kind: EntityEditorKind, fromPath: string, fromWs: string) => + onEntityMoved({ kind, path: fromPath, workspace: fromWs }) : undefined ) - // Follow a rename: the tab, its label, the chat's ACTIVE PREVIEW and the draft - // key all address the item by path, so they have to move with it or they name an - // item that no longer exists. + // The tab, its label, the chat's ACTIVE PREVIEW and the draft key all address + // the item by path, so they have to move with it or they name an item that no + // longer exists. const retargetTo = $derived( - slot.kind === 'entity' && runtime - ? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) => { - if (!stillShowing(kind, fromPath, fromWs)) return - runtime.previewTabs.retargetTabTo(tab.id, entityEditorHref(whereIs(tab), newPath)) - } + slot.kind === 'entity' + ? (kind: EntityEditorKind, newPath: string, fromPath: string, fromWs: string) => + onEntityMoved({ kind, path: fromPath, workspace: fromWs, to: newPath }) : undefined ) diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index 2a7dd6db50..85842ca881 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -230,11 +230,12 @@ const flushes = new SvelteMap() /** * Whether the last save that LANDED for a key deleted the draft or wrote one. - * Written only here, by the response handler, which is what distinguishes it - * from the display-level draft hint: that one is also published optimistically - * and by the editors themselves, so it cannot say whether a POST succeeded. - * A caller acting on a delete (leaving an editor whose item it removed) needs - * the difference. Never cleared — "last landed" is meaningless until one has. + * Written only by the response handler, which is what distinguishes it from the + * display-level draft hint: that one is also published optimistically and by the + * editors themselves, so it cannot say whether a POST succeeded. A caller acting + * on a delete (leaving an editor whose item it removed) needs the difference. + * Dropped when the server stops being ours to describe — a conflict, or a resync + * from a load — and otherwise kept, since the key's own next landing replaces it. */ const lastLanded = new Map() @@ -609,10 +610,9 @@ export const UserDraftDbSyncer = { /** * Whether the last save that landed for this key was the draft's deletion. - * False while none has landed — a delete that failed or conflicted never - * reaches the response handler, so it never claims to have landed, and one - * that landed earlier is dropped as soon as the server's state moves outside - * our writes. + * False while none has landed: a delete that failed or conflicted never + * reaches the response handler, and a conflict drops what an earlier one + * recorded rather than letting it answer for this attempt. */ lastLandedWasDelete(query: UserDraftLastSyncQuery): boolean { return lastLanded.get(draftKey(query.workspace, query.itemKind, query.path)) === 'delete' diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index fd675ad7be..92dbefb2cf 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -64,8 +64,11 @@ matchPreviewPage, pageKey, parseArtifactRoute, + entityEditorHref, entityKindForPage, entityListHref, + entityListPage, + type EntityEditorKind, parseEntityEditorRoute, parsePreviewItemRoute, previewLocationLabel, @@ -608,6 +611,32 @@ } } } + // A hosted entity editor moved its item — renamed it, or removed it by + // discarding the draft that was all of it. Every tab on that item follows, + // across warm sessions: they share the one draft cell and the one server row, + // so a tab left behind edits a path that is gone. Matching on the item is also + // what keeps a report that landed late from moving a tab that has since been + // re-pointed — there is nothing to match. + function moveEntityTabs(ev: { + kind: EntityEditorKind + path: string + workspace: string + to?: string + }) { + const page = entityListPage(ev.kind)?.path + if (!page) return + for (const s of warmSessions) { + const owner = getRuntime(s.id)?.previewTabs + if (!owner || getEffectiveWorkspaceId(s) !== ev.workspace) continue + for (const tab of owner.tabs) { + const loc = whereIs(tab) + const entity = parseEntityEditorRoute(loc) + if (!entity || entity.path !== ev.path || stripBase(loc) !== page) continue + // The tab's own location, so the list it came from keeps its filters. + owner.retargetTabTo(tab.id, ev.to ? entityEditorHref(loc, ev.to) : entityListHref(loc)) + } + } + } function flushReload() { const pages = pendingPages const mutations = pendingMutations @@ -1160,6 +1189,7 @@ {fullscreen} onNavigate={navigateEditorTo} onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)} + onEntityMoved={moveEntityTabs} /> {/each} {/each}