From 08386433aa5f69fc0bcfb4d32126a62ad561ef9f Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 13:50:54 +0200 Subject: [PATCH] fix(sessions): send a write's settled draft before the list frame re-reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settle that follows a write parks its draft delete on the autosave debounce, so the list frame reloaded straight after read the row's `*` and kept it: its markers come from a store inside the iframe, and nothing reloads it a second time when the delete lands. The fan-out sends what is parked for the key first. That needed the variable save to report after settling rather than before — reporting first meant there was nothing parked yet to send, and the flush passed over a cell about to queue a delete. Every editor now resolves its cell before saying so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/VariableEditor.svelte | 11 ++++++----- frontend/src/lib/userDraft.svelte.ts | 16 +++++++++++++++- .../routes/(root)/(logged)/sessions/+page.svelte | 9 +++++++-- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 7b78bd2216..c3e0c422d5 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -339,11 +339,6 @@ existedInitially[ws] = true } if (ws === fromWs) actingCommitted = true - // Per workspace, as each write lands. Each carries its own workspace and - // the path it wrote there, so a linked workspace's rename moves the tabs - // acting on it and no others — and a workspace written before a later one - // threw is still reported, because it is deployed. - onSaved?.(s.path, from, ws) // The just-saved state is the new deployed baseline; this resets the handle // to it via `discard` (not `remove` — blanking the cell to `undefined` reads // as dirty) and keeps an edit made mid-request. Each workspace settles @@ -351,6 +346,12 @@ settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, { workspace: ws }) + // Reported after settling, so what hears about the write sees a cell that + // has already been resolved. Per workspace, each carrying its own and the + // path it wrote there, so a linked workspace's rename moves the tabs acting + // on it and no others — and a workspace written before a later one threw is + // still reported, because it is deployed. + onSaved?.(s.path, from, ws) // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index f64d6986ae..8e7585dfb7 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -290,6 +290,20 @@ export function settleDraftAfterWrite( } } +/** + * Send whatever this cell has parked on the autosave debounce, now. For a reader + * that cannot be corrected once it has read — a list page inside an iframe, whose + * draft markers come from its own store — and would otherwise render a row the + * debounce has not caught up with. + */ +export async function flushDraftWrites( + itemKind: UserDraftItemKind, + path: string, + opts?: UserDraftOptions +): Promise { + await UserDraftDbSyncer.flush({ workspace: resolveWorkspace(opts), itemKind, path }) +} + /** * Wait for a queued draft delete to land, reporting whether the draft is gone. A * caller acts on this — a host leaves an editor whose item the discard removed — @@ -304,7 +318,7 @@ export async function flushDraftDelete( opts?: UserDraftOptions ): Promise { const query = { workspace: resolveWorkspace(opts), itemKind, path } - await UserDraftDbSyncer.flush(query) + await flushDraftWrites(itemKind, path, opts) return ( UserDraftDbSyncer.lastLandedWasDelete(query) && UserDraftDbSyncer.getState(query).state === 'none' diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 09e6c70fd0..3b55a02f9c 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -90,7 +90,7 @@ type WorkspaceItemKind } from '$lib/components/workspacePicker' import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, flushDraftWrites } from '$lib/userDraft.svelte' const globalEnabled = isGlobalAiEnabled() @@ -614,7 +614,7 @@ // A hosted entity editor wrote its item. Every tab on it hears, across warm // sessions: they share the one draft cell and the one server row, so one left // behind edits a path that is gone or discards over a value already deployed. - function entityWritten(ev: { + async function entityWritten(ev: { kind: EntityEditorKind path: string workspace: string @@ -625,6 +625,11 @@ }) { const page = entityListPage(ev.kind)?.path if (!page) return + // The settle that follows a write parks its draft delete on the autosave + // debounce, and a list frame reads its `*` markers from a store of its own + // — read before that lands, it keeps the row's marker until something else + // reloads it, and nothing does. + await flushDraftWrites(ev.kind, ev.path, { workspace: ev.workspace }) for (const s of warmSessions) { const owner = getRuntime(s.id)?.previewTabs if (!owner || getEffectiveWorkspaceId(s) !== ev.workspace) continue