diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index c3130fc624..38098a7e68 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -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) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index c3e0c422d5..ff0e581e9e 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -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 diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 3898b817d2..685e739773 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -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(), diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 8e7585dfb7..1f4f0da9df 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -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( +export async function settleDraftAfterWrite( itemKind: UserDraftItemKind, written: V, live: V | undefined, fromPath: string, savedPath: string, opts?: UserDraftOptions -): void { +): Promise { 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) } } diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 3b55a02f9c..f92c8373e4 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -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 | undefined + let draftLandedPages = new Set() + $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) => {