fix(sessions): keep what a released editor was holding, and scope the ws selector to the drawer

An editor stays editable while its save is in flight, so a tab left in that
window releases a cell that may carry an edit newer than the write — and a
released cell reads as no cell at all, which the settle took for a draft nothing
was watching and deleted. Keep what the last holder let go of, bounded like the
other write markers, and let the settle read that when no handle is left.

The linked-workspace selector follows the hand-off button: a session tab is keyed
to one workspace, so re-pointing the editor at a linked one from inside a tab
acted on an item the session is not about.

A removal marker now also requires the delete to be what landed, as
`flushDraftDelete` does: an upsert queued behind it displaces the delete, and
waiting for the chain then reports idle on a draft that is back.

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 19:49:10 +02:00
co-authored by Claude Opus 5
parent e65929665c
commit 3581a0e380
4 changed files with 62 additions and 14 deletions
@@ -206,12 +206,18 @@
>
History
</Button>
<WsSpecificVersions
kind="resource"
workspaceId={effectiveWorkspace}
initialPath={path}
bind:selected
/>
<!-- Drawer-only, for the same reason as the hand-off above: a session tab is
keyed to one workspace — its title, the chat's write targeting and the
deploy set all read it — so re-pointing the editor at a linked workspace
from inside one would act on an item the session is not about. -->
{#if useDrawer}
<WsSpecificVersions
kind="resource"
workspaceId={effectiveWorkspace}
initialPath={path}
bind:selected
/>
{/if}
{/if}
<Button
variant="accent"
@@ -429,7 +429,11 @@
{#if useDrawer}
<OpenInSessionButton source={sessionSource} />
{/if}
{#if edit && curWs}
<!-- Drawer-only, for the same reason as the hand-off above: a session tab is
keyed to one workspace — its title, the chat's write targeting and the
deploy set all read it — so re-pointing the editor at a linked workspace
from inside one would act on an item the session is not about. -->
{#if edit && curWs && useDrawer}
<WsSpecificVersions kind="variable" workspaceId={curWs} {initialPath} bind:selected />
{/if}
<Button
@@ -6184,10 +6184,13 @@ async function discardLocalDraft(
// not leave one standing for a later action to spend, and an edit made across the
// delete recreates the draft — leaving the item the marker would report as gone.
if (removesItem && discardedKind) {
const settled =
UserDraftDbSyncer.getState({ workspace, itemKind: discardedKind, path: storagePath })
.state === 'none'
if (settled) {
const query = { workspace, itemKind: discardedKind, path: storagePath }
// Settled AND settled on the delete: an upsert queued behind it displaces the
// delete, and waiting for the chain then reports idle on a draft that is back.
const removed =
UserDraftDbSyncer.getState(query).state === 'none' &&
UserDraftDbSyncer.lastLandedWasDelete(query)
if (removed) {
UserDraft.recordDraftOnlyDiscard(discardedKind, storagePath, { workspace })
}
}
+38 -3
View File
@@ -142,6 +142,36 @@ function noteMarker(set: Set<string>, key: string): void {
set.delete(oldest)
}
}
/**
* What a cell held when its last holder let go. An editor stays editable while
* its save is in flight, so a tab closed in that window releases a cell that may
* carry an edit newer than the write — and once released there is no handle left
* to read it from. Same cap and reason as the marker sets; taken by whoever asks
* (`settleDraftAfterWrite`), and dropped when the key is acquired again, since a
* live entry answers for itself.
*/
const releasedValues = new Map<string, unknown>()
/** Read once: what it describes is settled by the caller that reads it. */
function takeReleasedValue<V>(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftOptions
): V | undefined {
const mk = mapKey(resolveWorkspace(opts), itemKind, path)
const value = releasedValues.get(mk) as V | undefined
releasedValues.delete(mk)
return value
}
function noteReleasedValue(key: string, value: unknown): void {
releasedValues.set(key, value)
while (releasedValues.size > MAX_WRITE_MARKERS) {
const oldest = releasedValues.keys().next().value
if (oldest === undefined || oldest === key) break
releasedValues.delete(oldest)
}
}
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
/**
* Map keys whose entry should start `syncSuspended` on acquire. Lets
@@ -285,9 +315,12 @@ export async function settleDraftAfterWrite<V>(
savedPath: string,
opts?: UserDraftOptions
): Promise<void> {
// No cell at all is not a newer edit — the editor has been released, and its
// draft is precisely what nothing is watching any more.
const diverged = live !== undefined && !draftValuesEqual(live, written)
// A released cell reads as no cell at all, and its draft is then precisely what
// nothing is watching any more — except when the release itself is what took
// the handle away mid-write, in which case what it held still speaks for the
// user. Absent both, there is nothing newer to protect.
const held = live !== undefined ? live : takeReleasedValue<V>(itemKind, fromPath, opts)
const diverged = held !== undefined && !draftValuesEqual(held, written)
if (savedPath === fromPath && diverged) return
// Sent before this resolves, not left on the keystroke debounce: callers report
// the write and remount on it, and both read a cell this has to have finished
@@ -890,6 +923,7 @@ function acquireEntry(
canBeDisabled = false
): void {
const mk = mapKey(workspace, itemKind, path)
releasedValues.delete(mk)
const existing = entries.get(mk)
if (existing) {
existing.count++
@@ -1026,6 +1060,7 @@ function releaseEntry(mk: string): void {
// only here, once, at refcount 0. This is what lets multiple holders (warm
// session previews + the nav editor) share the entry and drop in any order.
if (entry.count <= 0) {
noteReleasedValue(mk, snapshotDraftValue(entry.state.val))
// The live entry was authoritative while mounted; once gone, drop any
// cached write for this key so a later read falls back to the server
// rather than a value the editor may have changed in the meantime.