fix: let a resolution tell it belongs to an editor that is gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-19 23:55:35 +02:00
co-authored by Claude Opus 5
parent 0b3fa66b64
commit f6c4ebbfb2
2 changed files with 29 additions and 9 deletions
@@ -265,6 +265,14 @@
/** A resolution is in flight. Both buttons go disabled: clicking the other one midway would
* race two resolutions of one conflict against each other. */
let resolvingConflict = $state(false)
/** Identifies the editor instance and the resolution within it. Comparing `selected`/path
* alone is not enough: closing and reopening the same item gives a *new* editor those same
* values, so a resolution left over from the old one would pass that check and write into it.
* Bumped per resolution and zeroed on teardown, so a stale one can always tell it is stale. */
let resolveGeneration = 0
onDestroy(() => {
resolveGeneration = -1
})
/** The server refused this tab's autosave because the row moved under it: another tab, or the
* AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
* picks a version, and the unsaved-changes banner says the opposite — that the edits are held
@@ -282,8 +290,10 @@
async function resolveDraftConflict(keepMine: boolean): Promise<void> {
const ws = selected
const p = initialPath
if (!ws || !p || resolvingConflict) return
if (!ws || !p || resolvingConflict || resolveGeneration < 0) return
const query = { workspace: ws, itemKind: 'resource' as const, path: p }
const gen = ++resolveGeneration
const stillOurs = () => gen === resolveGeneration && selected === ws && initialPath === p
resolvingConflict = true
try {
if (keepMine) {
@@ -291,7 +301,7 @@
// write below, and being conditional it would be refused — so "Keep mine" would
// finish without keeping anything and leave the alert standing.
await UserDraftDbSyncer.quiesce(query)
if (selected !== ws || initialPath !== p) return
if (!stillOurs()) return
// Forced, so it goes over the row that refused us, and its response reseeds
// `last_sync` so the next ordinary save is conditional again.
const mine = states[ws]?.draft
@@ -317,13 +327,13 @@
// resource's: the drawer stays closable while the read is out, and another resource
// opened meanwhile would otherwise get this one's baseline — and with it this one's
// path as its save target.
if (selected !== ws || initialPath !== p) return
if (!stillOurs()) return
// Anything an autosave queued while the read was out belongs to the version being
// replaced. Dropping is not enough on its own: a POST the runner already started
// cannot be cancelled, and if it settles after the baseline below, its rejection
// raises the conflict again. So wait for the chain to go quiet first.
await UserDraftDbSyncer.quiesce(query)
if (selected !== ws || initialPath !== p) return
if (!stillOurs()) return
UserDraftDbSyncer.clearConflict(query)
initialStates[ws] = structuredClone(deployedState)
UserDraftDbSyncer.recordRemoteSync(query, (r as any).draft_saved_at)
@@ -1,6 +1,6 @@
<script lang="ts">
import { VariableService, WorkspaceService } from '$lib/gen'
import { createEventDispatcher, untrack } from 'svelte'
import { createEventDispatcher, onDestroy, untrack } from 'svelte'
import { workspaceStore } from '$lib/stores'
import { Button } from './common'
import Drawer from './common/drawer/Drawer.svelte'
@@ -128,6 +128,14 @@
/** A resolution is in flight. Both buttons go disabled: clicking the other one midway would
* race two resolutions of one conflict against each other. */
let resolvingConflict = $state(false)
/** Identifies the editor instance and the resolution within it. Comparing `selected`/path
* alone is not enough: closing and reopening the same item gives a *new* editor those same
* values, so a resolution left over from the old one would pass that check and write into it.
* Bumped per resolution and zeroed on teardown, so a stale one can always tell it is stale. */
let resolveGeneration = 0
onDestroy(() => {
resolveGeneration = -1
})
/** The server refused this tab's autosave because the row moved under it: another tab, or the
* AI chat, which writes these drafts too. Nothing typed here reaches the server until the user
* picks a version, and the unsaved-changes banner says the opposite — that the edits are held
@@ -145,8 +153,10 @@
async function resolveDraftConflict(keepMine: boolean): Promise<void> {
const ws = selected
const p = editPath
if (!ws || !p || resolvingConflict) return
if (!ws || !p || resolvingConflict || resolveGeneration < 0) return
const query = { workspace: ws, itemKind: 'variable' as const, path: p }
const gen = ++resolveGeneration
const stillOurs = () => gen === resolveGeneration && selected === ws && editPath === p
resolvingConflict = true
try {
if (keepMine) {
@@ -154,7 +164,7 @@
// write below, and being conditional it would be refused — so "Keep mine" would
// finish without keeping anything and leave the alert standing.
await UserDraftDbSyncer.quiesce(query)
if (selected !== ws || editPath !== p) return
if (!stillOurs()) return
// Forced, so it goes over the row that refused us, and its response reseeds
// `last_sync` so the next ordinary save is conditional again.
const mine = states[ws]?.draft
@@ -188,13 +198,13 @@
// variable's: the drawer stays closable while the read is out, and another variable
// opened meanwhile would otherwise get this one's baseline — and with it this one's
// path as its save target.
if (selected !== ws || editPath !== p) return
if (!stillOurs()) return
// Anything an autosave queued while the read was out belongs to the version being
// replaced. Dropping is not enough on its own: a POST the runner already started
// cannot be cancelled, and if it settles after the baseline below, its rejection
// raises the conflict again. So wait for the chain to go quiet first.
await UserDraftDbSyncer.quiesce(query)
if (selected !== ws || editPath !== p) return
if (!stillOurs()) return
UserDraftDbSyncer.clearConflict(query)
initialStates[ws] = structuredClone(deployedState)
UserDraftDbSyncer.recordRemoteSync(query, (v as any).draft_saved_at)