fix: let a deployed item's editor settle its own row instead of discarding it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 15:04:35 +02:00
co-authored by Claude Opus 5
parent b4ed4f52c5
commit 184af46829
3 changed files with 52 additions and 5 deletions
@@ -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,
@@ -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
+31
View File
@@ -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' }