fix(sessions): move every tab on an item the editor renamed or removed, not just its own

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
Diego Imbert
2026-09-06 06:18:45 +02:00
co-authored by Claude Opus 5
parent 9d7b06ecc4
commit 345f40affb
3 changed files with 64 additions and 42 deletions
@@ -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
)
+9 -9
View File
@@ -230,11 +230,12 @@ const flushes = new SvelteMap<string, number>()
/**
* 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<string, 'delete' | 'upsert'>()
@@ -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'
@@ -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}