From fbfa822c0be762e9a35aad662b0e2dd591085822 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 20 Sep 2026 00:15:36 +0200 Subject: [PATCH] fix: show the resource conflict in the fixed banner, and scope busy to its session Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/components/ResourceEditor.svelte | 36 +++++++++++-------- .../components/ResourceEditorDrawer.svelte | 11 ++++++ .../src/lib/components/VariableEditor.svelte | 14 +++++--- 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 0763701468..c143eb21b6 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -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 @@
- {#if draftConflict} - void resolveDraftConflict(false)} - onOverwrite={() => void resolveDraftConflict(true)} - /> - {/if} - {#if otherDirty.length > 0} You are going to edit the value in: {otherDirty.join(', ')} diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 153eb58d5d..f822631c1d 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -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} + resourceEditor?.resolveDraftConflictFromBanner?.(false)} + onOverwrite={() => resourceEditor?.resolveDraftConflictFromBanner?.(true)} + /> + {/if} !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 } }