From 79f61f49b58c2f87f831a3cc90fb4b7d5eefd7b9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 18 Sep 2026 21:36:07 +0200 Subject: [PATCH] fix: tell the user when a draft conflict has stopped their edits saving Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/components/DraftConflictAlert.svelte | 25 ++++++++ .../src/lib/components/ResourceEditor.svelte | 56 ++++++++++++++++ .../src/lib/components/VariableEditor.svelte | 64 +++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 frontend/src/lib/components/DraftConflictAlert.svelte diff --git a/frontend/src/lib/components/DraftConflictAlert.svelte b/frontend/src/lib/components/DraftConflictAlert.svelte new file mode 100644 index 0000000000..03e7bbbb42 --- /dev/null +++ b/frontend/src/lib/components/DraftConflictAlert.svelte @@ -0,0 +1,25 @@ + + + +
+
+ It was saved from another tab or session since this one read it, so your changes here are no + longer being saved. +
+
+ + +
+
+
diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index bcb886ee7d..dfdcdfd686 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -20,6 +20,7 @@ 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' @@ -261,6 +262,54 @@ ) const anyDirty = $derived(dirtyWorkspaces.length > 0) + /** 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 + * as a draft — so without this they are told their work is safe while it is being dropped. */ + const draftConflict = $derived( + selected && initialPath + ? UserDraftDbSyncer.getConflict({ + workspace: selected, + itemKind: 'resource', + path: initialPath + }).conflict + : undefined + ) + + async function resolveDraftConflict(keepMine: boolean): Promise { + const ws = selected + const p = initialPath + if (!ws || !p) return + const query = { workspace: ws, itemKind: 'resource' as const, path: p } + if (keepMine) { + // 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 + if (mine) await UserDraftDbSyncer.overwrite({ ...query, value: $state.snapshot(mine) }) + return + } + // Taking theirs: drop the refused payload first so no later flush can send it, then read + // what the server holds and seed that in, which is also what gives this tab a baseline. + UserDraftDbSyncer.dropPending(query) + UserDraftDbSyncer.clearConflict(query) + const r = await ResourceService.getResource({ workspace: ws, path: p, getDraft: true }) + const deployedState: ResourceState = { + path: r.path, + args: (r.value ?? {}) as Record, + description: r.description ?? '', + labels: r.labels ?? undefined, + wsSpecific: r.ws_specific ?? false + } + initialStates[ws] = structuredClone(deployedState) + UserDraftDbSyncer.recordRemoteSync(query, (r as any).draft_saved_at) + UserDraft.seed( + 'resource', + p, + ((r as any).draft as ResourceState | undefined) ?? deployedState, + { workspace: ws } + ) + } + // The syncer owns the list-page `*` hint; the editor only CLEARS it when a // workspace is at the deployed baseline (so a draft discarded elsewhere // vanishes on reopen). Never SET here. See VariableEditor for the full note. @@ -531,6 +580,13 @@
+ {#if draftConflict} + resolveDraftConflict(false)} + onOverwrite={() => resolveDraftConflict(true)} + /> + {/if} + {#if otherDirty.length > 0} You are going to edit the value in: {otherDirty.join(', ')} diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 7c935be45c..f3002803ae 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -23,6 +23,8 @@ import { useActingUser } from '$lib/actingUser.svelte' import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte' import LocalDraftBanner from './LocalDraftBanner.svelte' + import DraftConflictAlert from './DraftConflictAlert.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { isEncryptedDraftValue } from '$lib/encryptedDraft' import { setLocalDraftHint } from '$lib/localDraftHints.svelte' @@ -123,6 +125,62 @@ Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws])) ) + /** 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 + * as a draft — so without this they are told their work is safe while it is being dropped. */ + const draftConflict = $derived( + edit && selected && editPath + ? UserDraftDbSyncer.getConflict({ + workspace: selected, + itemKind: 'variable', + path: editPath + }).conflict + : undefined + ) + + async function resolveDraftConflict(keepMine: boolean): Promise { + const ws = selected + const p = editPath + if (!ws || !p) return + const query = { workspace: ws, itemKind: 'variable' as const, path: p } + if (keepMine) { + // 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 + if (mine) await UserDraftDbSyncer.overwrite({ ...query, value: $state.snapshot(mine) }) + return + } + // Taking theirs: drop the refused payload first so no later flush can send it, then read + // what the server holds and seed that in, which is also what gives this tab a baseline. + UserDraftDbSyncer.dropPending(query) + UserDraftDbSyncer.clearConflict(query) + const v = await VariableService.getVariable({ + workspace: ws, + path: p, + decryptSecret: false, + getDraft: true + }) + const deployedState: VariableState = { + path: v.path, + variable: { + value: v.value ?? '', + is_secret: v.is_secret, + description: v.description ?? '' + }, + labels: v.labels ?? undefined, + wsSpecific: v.ws_specific ?? false + } + initialStates[ws] = structuredClone(deployedState) + UserDraftDbSyncer.recordRemoteSync(query, (v as any).draft_saved_at) + UserDraft.seed( + 'variable', + p, + ((v as any).draft as VariableState | undefined) ?? deployedState, + { workspace: ws } + ) + } + // The list-page `*` hint is owned by UserDraftDbSyncer (set on save, cleared // on delete). The editor only CLEARS it — a workspace at the deployed // baseline has no draft, so drop any stale hint (this is how a draft @@ -317,6 +375,12 @@ on:close={drawer?.closeDrawer} > {#snippet banner()} + {#if draftConflict} + resolveDraftConflict(false)} + onOverwrite={() => resolveDraftConflict(true)} + /> + {/if}