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 d97bb35c0e..82c565c311 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2347,9 +2347,10 @@ describe('global AI tools', () => { } }) - // 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 () => { + // Deploying a draft writes the item, so an editor open on it is a baseline behind. Re-reading + // is the whole cleanup: discarding on top would throw away anything typed during the deploy, + // which was never part of it. + it('re-reads a live editor after a deploy and leaves the cleanup to it', async () => { const path = 'u/admin/deployed_res' seedBackendDraft('resource', path, { path, value: { a: 2 } }) const calls: string[] = [] @@ -2371,7 +2372,9 @@ describe('global AI tools', () => { preserveLiveDraft: true, deployed: true }) - expect(calls).toEqual(['refresh', 'discard']) + expect(calls).toEqual(['refresh']) + // The chat sends no delete of its own either: the row is the editor's to settle. + expect(getBackendDraft('resource', path)).toBeDefined() } finally { registerLiveItemBridge({ seed: () => false, diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 4243e20054..a6dabe4400 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -595,7 +595,20 @@ 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) + if (options.deployed) { + const refreshing = refreshLiveItem(workspace, itemKind, storagePath) + if (refreshing) { + // Re-reading the open editor is the whole cleanup, and discarding on top of it is + // wrong: the deploy carried the draft as it stood when it started, so anything typed + // since is a divergence from what was deployed and belongs in the row. The editor's + // own rule already drops the row when the two match and keeps it when they do not. + await refreshing + // Its reconcile only queued that; send it, so a caller awaiting this sees the server + // settled — with no draft, or with the newer edit. + await UserDraftDbSyncer.flush({ workspace, itemKind, path: storagePath }) + return + } + } const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) const live = options.preserveLiveDraft && liveDraft?.storagePath === storagePath diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index f13984fbba..a8a668a0a5 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -721,6 +721,37 @@ describe('item store: origins', () => { expect(rows.writes.at(-1)).toEqual({ path: 'u/me/r', value: fromChat }) }) + it('settles its own row when its draft is deployed, keeping anything typed during it', async () => { + const rows = fakeRows() + const store = createItemStore(rows.port) + const key: ItemKey = { workspace: 'w', kind: 'resource', path: 'u/me/r' } + const drafted = { ...deployedRes, description: 'the draft being deployed' } + let deployed = deployedRes + const { handle: item } = store.acquire( + key, + { workspace: 'w', path: 'u/me/r' }, + adapter(async () => ({ deployed, draft: (rows.sent.get('u/me/r') as Res) ?? drafted })) + ) + await settle() + expect(item.value).toEqual(drafted) + + // The deploy writes the draft to the item, then asks the editor to re-read. + deployed = drafted + expect(await item.reload()).toEqual({ ok: true }) + // Nothing was typed during it, so the row it was holding is now redundant and goes on its + // own. This is the cleanup, and is why the deploy must not discard on top of it. + expect(item.dirty).toBe(false) + expect(rows.writes.at(-1)).toEqual({ path: 'u/me/r', value: null }) + + // Now the same again with an edit made while the deploy was in flight. + const typed = { ...deployedRes, description: 'typed while deploying' } + item.value = typed + await rows.port.flush(key) + deployed = typed + expect(await item.reload()).toEqual({ ok: true }) + expect(item.value).toEqual(typed) + }) + it('keeps a draft-only item an outside write put back while its delete was going', async () => { const rows = fakeRows() const draft = { ...deployedRes, description: 'only a draft' }