fix: tell the user when a draft conflict has stopped their edits saving

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-18 21:36:07 +02:00
co-authored by Claude Opus 5
parent f00b2fcb1e
commit 79f61f49b5
3 changed files with 145 additions and 0 deletions
@@ -0,0 +1,25 @@
<script lang="ts">
import { Alert, Button } from '$lib/components/common'
interface Props {
/** Take the draft the server holds, replacing what is on screen. */
onReload: () => void
/** Write what is on screen over the server's draft. */
onOverwrite: () => void
}
let { onReload, onOverwrite }: Props = $props()
</script>
<Alert type="warning" title="Your draft changed elsewhere">
<div class="flex flex-col items-start gap-2">
<div>
It was saved from another tab or session since this one read it, so your changes here are no
longer being saved.
</div>
<div class="flex flex-row gap-2">
<Button unifiedSize="sm" variant="default" onClick={onReload}>Load the other version</Button>
<Button unifiedSize="sm" variant="default" onClick={onOverwrite}>Keep mine</Button>
</div>
</div>
</Alert>
@@ -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<void> {
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<string, any>,
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 @@
<div>
<div class="flex flex-col gap-6 pb-2">
{#if draftConflict}
<DraftConflictAlert
onReload={() => resolveDraftConflict(false)}
onOverwrite={() => resolveDraftConflict(true)}
/>
{/if}
{#if otherDirty.length > 0}
<Alert type="warning" title="Editing multiple workspaces">
You are going to edit the value in: {otherDirty.join(', ')}
@@ -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<void> {
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}
<DraftConflictAlert
onReload={() => resolveDraftConflict(false)}
onOverwrite={() => resolveDraftConflict(true)}
/>
{/if}
<LocalDraftBanner
show={edit && selectedDirty}
reserveSpace={edit}