diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte index 82946b10de..0ffb8bcdc3 100644 --- a/frontend/src/lib/components/AutosaveIndicator.svelte +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -81,6 +81,9 @@ savedVisible = false } else if (s === 'none' && prev === 'saving') { // A save just landed: flash "Saved" for SAVED_LABEL_MS. + // `discarding → none` deliberately falls through — wiping + // a draft isn't a save and "Saved" would read as "your + // draft just landed", the opposite of what happened. savedVisible = true if (timer) clearTimeout(timer) timer = setTimeout(() => { @@ -94,6 +97,15 @@ timer = undefined } savedVisible = false + } else if (s === 'discarding') { + // Reset-to-deployed in flight — squash any stale "Saved" + // flash so the UI doesn't briefly claim success while we + // wipe the draft. + if (timer) { + clearTimeout(timer) + timer = undefined + } + savedVisible = false } prev = s }) diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index 6fd307edb8..70cdb891ac 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -1,4 +1,4 @@ -import { SvelteMap } from 'svelte/reactivity' +import { SvelteMap, SvelteSet } from 'svelte/reactivity' import { DraftService, type UserDraftItemKind } from './gen' import { OpenAPI } from './gen/core/OpenAPI' import { createCoalescingKeyedRunner } from './coalescingRunner.svelte' @@ -96,19 +96,20 @@ export type UserDraftLastSyncQuery = { /** * Autosave lifecycle for a single draft, derived from the two-stage * pipeline: - * - `saving`: a POST is currently in flight (coalescing runner busy). - * - `pending`: a change is queued in the debouncer but not yet fired. - * - `failed`: the last POST threw (network / 5xx / etc.) and no later - * attempt has succeeded. Conflicts use the conflict modal - * path and don't surface here. - * - `none`: none of the above — the draft is in sync (or nothing - * happened). - * Priority on render: `saving` > `pending` > `failed` > `none`. An active - * save attempt outranks the prior failure so the indicator shows the - * retry as in-flight; once it settles, either success clears `failed` or - * a fresh throw sets it again. + * - `saving`: a regular save POST is in flight (coalescing runner busy). + * - `discarding`: a delete POST (value: null) is in flight — semantically a + * "reset to deployed", not a save. The indicator suppresses + * its spinner / "Saving..." / "Saved" UI for this state so + * the user isn't misled into thinking a draft is landing. + * - `pending`: a change is queued in the debouncer but not yet fired. + * - `failed`: the last POST threw (network / 5xx / etc.) and no later + * attempt has succeeded. Conflicts use the conflict modal + * path and don't surface here. + * - `none`: none of the above — the draft is in sync (or nothing + * happened). + * Priority on render: `saving` > `discarding` > `pending` > `failed` > `none`. */ -export type UserDraftSyncState = 'none' | 'pending' | 'saving' | 'failed' +export type UserDraftSyncState = 'none' | 'pending' | 'saving' | 'discarding' | 'failed' export type UserDraftStateHandle = { /** Reactive — read it inside a `$derived`/`$effect` and it re-runs as @@ -178,6 +179,16 @@ const conflicts = new SvelteMap() */ const failures = new SvelteMap() +/** + * Reactive set of draft keys whose currently in-flight POST is a discard + * (a `value: null` "reset to deployed" delete) rather than a regular + * save. Populated by `postSave` for the lifetime of the request so + * `getState` can return `'discarding'` instead of `'saving'`, and the + * indicator can stay quiet — Saving... → Saved would otherwise read as + * "your draft was just saved" while the user was actually wiping it. + */ +const discarding = new SvelteSet() + /** * Best-effort error → readable string. The generated client wraps HTTP * failures as `ApiError` with `body` / `statusText`; raw fetch errors @@ -201,6 +212,8 @@ function formatSaveError(e: unknown): string { async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise { const key = draftKey(opts.workspace, opts.itemKind, opts.path) + const isDiscard = opts.value === null + if (isDiscard) discarding.add(key) const lastSync = getLastSyncEntry(key)?.lastSync try { const resp = await DraftService.saveDraft({ @@ -253,6 +266,8 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise { // means we don't pretend the user's pending edit landed when // it didn't. failures.set(key, formatSaveError(e)) + } finally { + if (isDiscard) discarding.delete(key) } } @@ -355,7 +370,9 @@ export const UserDraftDbSyncer = { const key = draftKey(query.workspace, query.itemKind, query.path) return { get state(): UserDraftSyncState { - if (runner.isRunning(key)) return 'saving' + if (runner.isRunning(key)) { + return discarding.has(key) ? 'discarding' : 'saving' + } if (debouncer.isPending(key)) return 'pending' if (failures.has(key)) return 'failed' return 'none'