From b4ed4f52c5e3fd253e631bc6ed9f602000b28ac1 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 15 Sep 2026 14:50:47 +0200 Subject: [PATCH] fix: re-read a live editor before a deploy discards the draft it made Co-Authored-By: Claude Opus 5 (1M context) --- .../copilot/chat/global/core.test.ts | 38 +++++++++++++++++++ .../components/copilot/chat/global/core.ts | 5 ++- .../copilot/chat/global/userDraftAdapter.ts | 12 +++++- frontend/src/lib/itemStore.svelte.ts | 7 ++++ frontend/src/lib/userDraft.svelte.ts | 17 +++++++++ 5 files changed, 77 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 043ca9d794..d97bb35c0e 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2324,6 +2324,7 @@ describe('global AI tools', () => { registerLiveItemBridge({ seed: () => false, read: () => undefined, + refresh: () => undefined, // Claims the key and removes nothing, so anything still gone was deleted by the chat. discard: () => { discards++ @@ -2339,6 +2340,43 @@ describe('global AI tools', () => { registerLiveItemBridge({ seed: () => false, read: () => undefined, + refresh: () => undefined, + discard: () => undefined, + list: () => [] + }) + } + }) + + // Deploying a draft writes the item, so an editor open on it is a baseline behind. Its row is + // discarded straight after, which resets it to that baseline — so it has to re-read first. + it('refreshes a live editor before a deploy discards its draft', async () => { + const path = 'u/admin/deployed_res' + seedBackendDraft('resource', path, { path, value: { a: 2 } }) + const calls: string[] = [] + registerLiveItemBridge({ + seed: () => false, + read: () => undefined, + refresh: () => { + calls.push('refresh') + return Promise.resolve() + }, + discard: () => { + calls.push('discard') + return Promise.resolve({ removed: true }) + }, + list: () => [] + }) + try { + await deleteGlobalDraft(WORKSPACE, 'resource', path, undefined, { + preserveLiveDraft: true, + deployed: true + }) + expect(calls).toEqual(['refresh', 'discard']) + } finally { + registerLiveItemBridge({ + seed: () => false, + read: () => undefined, + refresh: () => undefined, discard: () => undefined, list: () => [] }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 5883c5ae0d..25903fccdd 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -7994,7 +7994,10 @@ async function deployDraft( // fork comparisons before the fallible draft cleanup below. invalidateWorkspaceComparison(workspace) - await deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true }) + await deleteGlobalDraft(workspace, type, path, triggerKind, { + preserveLiveDraft: true, + deployed: true + }) // Move the chat's mask entry to the deployed path: a draft-only item's // synthetic storage key never exists deployed, so the entry would otherwise diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 980a5d7fca..4243e20054 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -2,7 +2,12 @@ import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen' import { DraftService } from '$lib/gen' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' -import { UserDraft, type UserDraftEntry, type UserDraftItemKind } from '$lib/userDraft.svelte' +import { + refreshLiveItem, + UserDraft, + type UserDraftEntry, + type UserDraftItemKind +} from '$lib/userDraft.svelte' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import { getWorkspaceItemKey, @@ -574,6 +579,10 @@ export async function saveGlobalAppDraft( type DeleteGlobalDraftOptions = { preserveLiveDraft?: boolean + /** The deployed item was just written from this draft. An editor open on it is holding the + * baseline from before that, and discarding the draft resets it to exactly that — showing + * the pre-deploy value, clean, ready to be saved back over the deployment. */ + deployed?: boolean } export async function deleteGlobalDraft( @@ -586,6 +595,7 @@ export async function deleteGlobalDraft( const itemKind = itemKindFor(type, triggerKind) if (!itemKind) return const storagePath = resolveDraftStoragePath(workspace, itemKind, path) + if (options.deployed) await refreshLiveItem(workspace, itemKind, storagePath) const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) const live = options.preserveLiveDraft && liveDraft?.storagePath === storagePath diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index 77498a6121..7275faf44f 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -1089,6 +1089,13 @@ export function createItemStore(ports: ItemRowPort) { if (!entry || !entry.loaded) return undefined return { value: entry.dirty && entry.origin !== 'new' ? snapshot(entry.value) : undefined } }, + refresh( + workspace: string, + kind: UserDraftItemKind, + path: string + ): Promise | undefined { + return find(workspace, kind, path)?.reread() + }, discard( workspace: string, kind: UserDraftItemKind, diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 99dcf0922e..d6f9f9ede5 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -141,6 +141,13 @@ export type LiveItemBridge = { itemKind: UserDraftItemKind, path: string ): { value: unknown | undefined } | undefined + /** Re-read the live item, for a caller that has just written the deployed item under it. + * `undefined` when no live item holds the key. */ + refresh( + workspace: string, + itemKind: UserDraftItemKind, + path: string + ): Promise | undefined /** Discard the live item's draft, resolving once its delete has landed. `undefined` when no * live item holds the key, so the caller issues the delete itself. */ discard( @@ -153,6 +160,16 @@ export type LiveItemBridge = { let liveItems: LiveItemBridge | undefined +/** Tell a live item that the deployed item under it has just been written, so its baseline is + * current before anything resets to it. Resolves once it has re-read. */ +export function refreshLiveItem( + workspace: string, + itemKind: UserDraftItemKind, + path: string +): Promise | undefined { + return liveItems?.refresh(workspace, itemKind, path) +} + export function registerLiveItemBridge(bridge: LiveItemBridge): void { liveItems = bridge }