+ 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}