fix(drafts): finish settling a write before anything reads it, and follow late landings

Timing the fan-out against the settle was the wrong direction. The settle now
sends its own delete and resolves when it has gone out, so the editors report —
and the schedule host remounts — on a cell that is already resolved: a stalled
delete used to let that remount load the draft the save had just replaced, and
restore it over the deployed value.

A write can still land after all of that, when an edit made during the save is
kept. A list frame reads its rows from a store of its own and nothing reloads it
again, so it now reloads on the write landing rather than on our guess about
when it will.

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 14:27:08 +02:00
co-authored by Claude Opus 5
parent 08386433aa
commit 5b99cce175
5 changed files with 54 additions and 13 deletions
@@ -392,7 +392,9 @@
written.push({ ws, path: s.path })
// `s.path`, not the reported one: each workspace settles against the path
// its own write used.
settleDraftAfterWrite('resource', s, states[ws]?.draft, from, s.path, { workspace: ws })
await settleDraftAfterWrite('resource', s, states[ws]?.draft, from, s.path, {
workspace: ws
})
// Path now exists server-side — drop the autocomplete cache so
// it shows up immediately instead of after the 60s TTL.
invalidateWorkspacePaths(ws)
@@ -343,7 +343,7 @@
// to it via `discard` (not `remove` — blanking the cell to `undefined` reads
// as dirty) and keeps an edit made mid-request. Each workspace settles
// against the path its own write used, not the reported one.
settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, {
await settleDraftAfterWrite('variable', s, states[ws]?.draft, from ?? '', s.path, {
workspace: ws
})
// Reported after settling, so what hears about the write sees a cell that
@@ -647,7 +647,9 @@
// next save updates or creates, and whether a discard removes the item.
edit = true
draftOnly = false
settleDraftAfterWrite(
// Awaited before the host hears: it remounts on the report, and a remount
// that overtook this would load the draft this is removing.
await settleDraftAfterWrite(
'trigger_schedule',
scheduleCfg,
getScheduleCfg(),
@@ -780,7 +782,7 @@
// It matches the new one, so nothing would ever show it again — but it is
// still a row on the server and a `*` on the list until it is settled away.
if (initialConfig)
settleDraftAfterWrite(
await settleDraftAfterWrite(
'trigger_schedule',
$state.snapshot(initialConfig),
getScheduleCfg(),
+6 -2
View File
@@ -277,16 +277,20 @@ export function draftValuesEqual(a: unknown, b: unknown): boolean {
* only its own default), so the cell is reset rather than left orphaned under a
* path the item no longer occupies.
*/
export function settleDraftAfterWrite<V>(
export async function settleDraftAfterWrite<V>(
itemKind: UserDraftItemKind,
written: V,
live: V | undefined,
fromPath: string,
savedPath: string,
opts?: UserDraftOptions
): void {
): Promise<void> {
if (savedPath !== fromPath || draftValuesEqual(live, written)) {
UserDraft.discard(itemKind, fromPath, written, opts)
// 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 resolving — including through a frame with a draft store of its own.
await flushDraftWrites(itemKind, fromPath, opts)
}
}
@@ -90,7 +90,8 @@
type WorkspaceItemKind
} from '$lib/components/workspacePicker'
import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture'
import { UserDraft, flushDraftWrites } from '$lib/userDraft.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
const globalEnabled = isGlobalAiEnabled()
@@ -614,7 +615,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.
async function entityWritten(ev: {
function entityWritten(ev: {
kind: EntityEditorKind
path: string
workspace: string
@@ -625,11 +626,6 @@
}) {
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
@@ -665,6 +661,43 @@
pendingMutations = []
reloadTabs(pages, mutations)
}
// A list frame reads its rows and `*` markers from a store inside the iframe, so
// nothing we do afterwards corrects what it already read. Rather than time our
// reloads against writes, reload when one lands: a draft written after a save —
// an edit made while it was in flight — reaches the list this way too.
let draftLandedHandle: ReturnType<typeof setTimeout> | undefined
let draftLandedPages = new Set<string>()
$effect(() => {
const stop = UserDraftDbSyncer.onAnySaved(({ workspace, itemKind }) => {
const page = entityListPage(itemKind as EntityEditorKind)?.path
if (!page) return
draftLandedPages.add(`${workspace}\u0000${page}`)
clearTimeout(draftLandedHandle)
// Coalesced: a deploy lands several writes for one item back to back.
draftLandedHandle = setTimeout(() => {
const pages = draftLandedPages
draftLandedPages = new Set()
for (const s of warmSessions) {
const ws = getEffectiveWorkspaceId(s)
const owner = getRuntime(s.id)?.previewTabs
if (!ws || !owner) continue
for (const tab of owner.tabs) {
const loc = whereIs(tab)
if (parseEntityEditorRoute(loc)) continue
if (!pages.has(`${ws}\u0000${stripBase(loc)}`)) continue
const key = tabKey(s.id, tab.id)
if (mountedTabKeys.has(key)) tabHosts[key]?.reload()
}
}
}, 300)
})
return () => {
clearTimeout(draftLandedHandle)
draftLandedPages = new Set()
stop()
}
})
$effect(() => {
// Debounced so a burst of writes (the AI editing several files) reloads once.
setToolCompletionListener((name, args, workspace) => {