fix: show the resource conflict in the fixed banner, and scope busy to its session

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-20 00:15:36 +02:00
co-authored by Claude Opus 5
parent bb2a65d618
commit fbfa822c0b
3 changed files with 41 additions and 20 deletions
@@ -20,7 +20,6 @@
import { useActingUser } from '$lib/actingUser.svelte'
import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import DraftConflictAlert from './DraftConflictAlert.svelte'
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
import { onUserInput } from '$lib/userDraftEditGate'
@@ -47,6 +46,9 @@
* so it can hide the banner's Discard button in read-only mode (matches
* the trigger editors' `disabled={!can_write}` wiring). */
onCanWriteChange?: (canWrite: boolean) => void
/** The drawer renders the conflict alert: inside this scrollable form it can land above the
* viewport on a long resource, leaving the fixed banner claiming the edits are saved. */
onDraftConflictChange?: (state: { conflicted: boolean; busy: boolean }) => void
}
let {
@@ -60,7 +62,8 @@
selected: selectedProp = $bindable(),
viewJsonSchema = $bindable(),
onDraftStateChange,
onCanWriteChange
onCanWriteChange,
onDraftConflictChange
}: Props = $props()
type ResourceState = {
@@ -262,9 +265,6 @@
)
const anyDirty = $derived(dirtyWorkspaces.length > 0)
/** 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)
/** Which editing session a conflict resolution belongs to. Comparing `selected`/path is not
* enough — this component outlives the drawer and reopening the same resource reuses it with
* those same values — so a resolution carries the session it started in and every step checks
@@ -276,6 +276,11 @@
resolveGeneration++
}
onDestroy(endEditingSession)
/** The session whose resolution is in flight, or 0. Scoped by generation rather than a plain
* flag: a request left over from a closed session must not leave the next one showing busy
* buttons it can never un-disable, and its `finally` must not clear a newer one's. */
let resolvingFor = $state(0)
const resolvingConflict = $derived(resolvingFor !== 0 && resolvingFor === resolveGeneration)
/** 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
@@ -297,7 +302,7 @@
const query = { workspace: ws, itemKind: 'resource' as const, path: p }
const gen = ++resolveGeneration
const stillOurs = () => gen === resolveGeneration && selected === ws
resolvingConflict = true
resolvingFor = gen
try {
if (keepMine) {
// Settle the key first: an ordinary autosave still queued would displace the forced
@@ -351,7 +356,9 @@
// resolve again — which is the whole point of reading first.
sendUserToast(`Could not load the other version: ${e}`, true)
} finally {
resolvingConflict = false
// Only if it is still ours: a stale one settling later must not clear the busy state of
// the session that replaced it.
if (resolvingFor === gen) resolvingFor = 0
}
}
@@ -518,6 +525,13 @@
$effect(() => {
onCanWriteChange?.(can_write === true)
})
$effect(() => {
onDraftConflictChange?.({ conflicted: !!draftConflict, busy: resolvingConflict })
})
export function resolveDraftConflictFromBanner(keepMine: boolean): void {
void resolveDraftConflict(keepMine)
}
export function localDraftDeployed(): ResourceState | undefined {
return selected ? initialStates[selected] : undefined
@@ -625,14 +639,6 @@
<div>
<div class="flex flex-col gap-6 pb-2">
{#if draftConflict}
<DraftConflictAlert
busy={resolvingConflict}
onReload={() => void resolveDraftConflict(false)}
onOverwrite={() => void resolveDraftConflict(true)}
/>
{/if}
{#if otherDirty.length > 0}
<Alert type="warning" title="Editing multiple workspaces">
You are going to edit the value in: {otherDirty.join(', ')}
@@ -9,6 +9,7 @@
import { isOwner } from '$lib/utils'
import { useActingUser } from '$lib/actingUser.svelte'
import LocalDraftBanner from './LocalDraftBanner.svelte'
import DraftConflictAlert from './DraftConflictAlert.svelte'
import OpenInSessionButton from './sessions/OpenInSessionButton.svelte'
import {
clearPageDrawerAnchor,
@@ -50,9 +51,11 @@
localDraftCurrent: () => unknown
discardLocalDraft: () => void
endEditingSession: () => void
resolveDraftConflictFromBanner: (keepMine: boolean) => void
}
| undefined = $state(undefined)
let hasLocalDraft = $state(false)
let draftConflict = $state({ conflicted: false, busy: false })
let canWriteSelected = $state(true)
let path: string | undefined = $state(undefined)
@@ -166,10 +169,18 @@
bind:selected
bind:viewJsonSchema
onDraftStateChange={(v) => (hasLocalDraft = v)}
onDraftConflictChange={(v) => (draftConflict = v)}
onCanWriteChange={(v) => (canWriteSelected = v)}
/>
{/await}
{#snippet banner()}
{#if draftConflict.conflicted}
<DraftConflictAlert
busy={draftConflict.busy}
onReload={() => resourceEditor?.resolveDraftConflictFromBanner?.(false)}
onOverwrite={() => resourceEditor?.resolveDraftConflictFromBanner?.(true)}
/>
{/if}
<LocalDraftBanner
show={hasLocalDraft}
reserveSpace={mode == 'edit'}
@@ -125,9 +125,6 @@
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
)
/** 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)
/** Which editing session a conflict resolution belongs to. Comparing `selected`/path is not
* enough — reopening the same variable reuses this component and those same values — so a
* resolution carries the session it started in and every step checks it is still the current
@@ -139,6 +136,11 @@
resolveGeneration++
}
onDestroy(endEditingSession)
/** The session whose resolution is in flight, or 0. Scoped by generation rather than a plain
* flag: a request left over from a closed session must not leave the next one showing busy
* buttons it can never un-disable, and its `finally` must not clear a newer one's. */
let resolvingFor = $state(0)
const resolvingConflict = $derived(resolvingFor !== 0 && resolvingFor === resolveGeneration)
/** 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
@@ -160,7 +162,7 @@
const query = { workspace: ws, itemKind: 'variable' as const, path: p }
const gen = ++resolveGeneration
const stillOurs = () => gen === resolveGeneration && selected === ws
resolvingConflict = true
resolvingFor = gen
try {
if (keepMine) {
// Settle the key first: an ordinary autosave still queued would displace the forced
@@ -222,7 +224,9 @@
// resolve again — which is the whole point of reading first.
sendUserToast(`Could not load the other version: ${e}`, true)
} finally {
resolvingConflict = false
// Only if it is still ours: a stale one settling later must not clear the busy state of
// the session that replaced it.
if (resolvingFor === gen) resolvingFor = 0
}
}