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 f35aee94d7..3f6d61b783 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -3089,6 +3089,30 @@ describe('global AI tools', () => { expect(value?.summary).toBe('edited in the editor') }) + // The editor can also close during that window, which releases the cell and leaves its + // save in the backend row — newer than anything resolution saw. + it('prefers the backend draft over the resolved value once the cell is released', async () => { + const path = 'u/admin/released_cell' + seedBackendDraft( + 'script', + path, + { path, summary: 'saved on close', content: 'new', language: 'bun', kind: 'script' }, + { workspace: WORKSPACE } + ) + + // No cell: the editor that held one has closed. + const stale = { path, summary: 'as dispatched', content: 'old', language: 'bun' } + const value = await readGlobalDraftValue(WORKSPACE, 'script', { + path, + storagePath: path, + value: stale, + fetched: true + }) + + expect(value?.content).toBe('new') + expect(value?.summary).toBe('saved on close') + }) + // A rename typed in the editor is only in the cell; the backend row still carries the old // name. Matching the row anyway would let the old name keep selecting the draft. it('does not resolve a renamed draft by the name its backend row still carries', async () => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index a4a2c13e88..2081214c7e 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -8538,13 +8538,9 @@ async function deployDraft( // The shared deployer deploys at the path inside the draft and doesn't report it // back, so read it here — post-flush, since a rename may have been parked. This is // where a draft-only or renamed item lands, and what the callbacks below must name. + // Read after the flush above, which may have persisted a rename parked in the editor. deployedPath = - chosenDraftName( - type, - // `fresh`: the flush above may have persisted a rename parked in the editor, - // which the value read while resolving predates. - await readGlobalDraftValue(workspace, type, target, { fresh: true }) - ) ?? storagePath + chosenDraftName(type, await readGlobalDraftValue(workspace, type, target)) ?? storagePath const result = await deployDraftToWorkspace(type, storagePath, workspace, { draftOnly, deploymentMessage diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 9175ede400..9fadc8ab58 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -558,24 +558,25 @@ async function fetchBackendDraftValue( return resp.value ?? undefined } -/** Draft VALUE at a resolved target, in order: the cell if one is open (the user's freshest - * in-tab edits), else the value resolving the target already read, else the current user's - * backend draft. `fresh` skips the resolved value — for a caller that has since changed the - * draft it is reading. */ +/** Draft VALUE at a resolved target: the cell if an editor holds one (the user's freshest + * in-tab edits), else the current user's backend draft. + * + * Deliberately not the value resolving the target already read, though that would save a + * request. Both callers merge onto what this returns, and a tool awaits other work before its + * write — the provider catalog, a bundle. In that window the user can edit and then close the + * editor, which releases the cell and leaves the backend row newer than anything resolution + * saw; merging onto the older value silently reverts their save. Only the storage key is + * reused across that wait, never the contents. */ export async function readGlobalDraftValue( workspace: string, type: WorkspaceItemType, target: ResolvedDraftTarget, - opts: { triggerKind?: TriggerKind; fresh?: boolean } = {} + opts: { triggerKind?: TriggerKind } = {} ): Promise { const itemKind = itemKindFor(type, opts.triggerKind) if (!itemKind) return undefined - // Ahead of the resolved value, which was read when the call was dispatched: a tool awaits - // other work before its write (the provider catalog, a bundle), and an edit made in the - // editor during that window would be overwritten by the older snapshot. const cell = UserDraft.get(itemKind, target.storagePath, { workspace }) if (cell !== undefined) return cell - if (target.fetched && !opts.fresh) return target.value as V | undefined return (await fetchBackendDraftValue(workspace, itemKind, target.storagePath)) as V | undefined }