diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index b146a2ca07..c1432d5b82 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -249,6 +249,9 @@ class Entry { * each, and each row a caller says it wrote for one of them. A row left over after that is a * row nobody here has the value of. */ private values = 0 + /** Whether the last value handed in made this entry write a row of its own. Loading, it + * cannot: the caller's own row is then the only one, and is already counted. */ + private seedWroteRow = false private queue: Promise = Promise.resolve() private ports: ItemRowPort private store: StoreInternals @@ -528,18 +531,21 @@ class Entry { * rather than leaving the row to this entry: the two are one value, and counting the second * row as unaccounted would read as someone else having written it. */ noteRow(): void { - this.values++ + if (this.seedWroteRow) this.values++ } applyExternal(value: V): number { this.externals++ this.values++ + this.seedWroteRow = false if (this.loaded && serialize(value) === serialize(this.value)) return this.revision this.edits++ this.pristine = false this.removed = false this.replaceValue(value) + const rowsBefore = this.ports.rowMark(this.key) this.reconcile() + this.seedWroteRow = this.ports.rowMark(this.key) !== rowsBefore return this.revision } diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index dd531f2055..8cfda73988 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -906,6 +906,33 @@ describe('item store: origins', () => { expect(rows.writes.at(-1)).toEqual({ path: 'u/me/r', value: typed }) }) + it('is not fooled by the chat crediting a row a loading item never wrote', async () => { + const rows = fakeRows() + const read = deferred>() + const store = createItemStore(rows.port) + const key: ItemKey = { workspace: 'w', kind: 'resource', path: 'u/me/r' } + const { handle: item } = store.acquire( + key, + { workspace: 'w', path: 'u/me/r' }, + adapter(() => read.promise) + ) + await settle() + + // The chat seeds and persists while the first GET is out. Loading, this entry writes no + // row of its own, so the chat's save is the only row that value produced. + item.applyExternal({ ...deployedRes, description: 'from the chat' }) + store.bridge.noteRow('w', 'resource', 'u/me/r') + rows.handExternally('u/me/r') + // The legacy agent editor then writes the same resource: a row nobody here has. + rows.handExternally('u/me/r') + read.resolve({ deployed: deployedRes }) + await settle() + + // Two rows, one of them unaccounted for. Crediting the chat twice would hide it and let + // this editor post over the agent editor's newer draft. + expect(item.canSave).toBe(false) + }) + it('stays blocked when an unseen row follows the edit it kept', async () => { const rows = fakeRows() const read = deferred>()