From 0635901e7f9e5cd54e508efd39e5139e48aaabae Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 03:32:53 +0200 Subject: [PATCH] fix(editors): keep an edit made while a save is in flight, and land baselines per workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forms stay editable during a save, and the post-write reset dropped the local draft back to the state that was sent — so anything typed in between vanished when the request returned. The draft is now dropped only while it still holds what was written; an edit on top of the save survives as the unsaved change it is, and the hosts that remount pick it back up. A multi-workspace variable save also adopted its baselines only after every write had succeeded, so a later workspace throwing left an earlier one's draft dropped below a baseline that never moved: dirty forever, and retried as a create it had already made. Each lands as its own write does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../src/lib/components/ResourceEditor.svelte | 10 ++++++--- .../src/lib/components/VariableEditor.svelte | 21 ++++++++++++------- .../schedules/ScheduleEditorInner.svelte | 14 ++++++++++++- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index ddea758bf4..9920df30c9 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -366,10 +366,14 @@ }) } // Reset the handle to the new deployed baseline via `discard`, not - // `remove`. See VariableEditor for the full rationale. - initialStates[ws] = $state.snapshot(s) as ResourceState + // `remove`. See VariableEditor for the full rationale — including why the + // cell is only dropped while it still holds what was written. + const written = $state.snapshot(s) as ResourceState + initialStates[ws] = written existedInitially[ws] = true - UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws }) + if (draftValuesEqual(states[ws]?.draft, written)) { + UserDraft.discard('resource', initialPath ?? '', written, { workspace: ws }) + } // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 954ffcd85e..379b13532e 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -322,11 +322,22 @@ } }) } + // Per workspace, as each write lands: a later one throwing must not leave + // this one's draft dropped below a baseline that never moved, which reads + // as dirty and retries the create it already made. + if (editPath === from) { + initialStates[ws] = s + existedInitially[ws] = true + } // The just-saved state is the new deployed baseline; reset the // handle to it via `discard` (not `remove` — blanking the cell to // `undefined` reads as dirty). The `value: null` POST also deletes - // the server draft row so `is_draft` clears on refetch. - UserDraft.discard('variable', from ?? '', s, { workspace: ws }) + // the server draft row so `is_draft` clears on refetch. Only while the + // cell still holds what was written: the form stays editable during the + // request, and an edit made then is a change on top of the save. + if (draftValuesEqual(states[ws]?.draft, s)) { + UserDraft.discard('variable', from ?? '', s, { workspace: ws }) + } // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) @@ -336,12 +347,8 @@ ) dispatch('create') // Only while this editor is still the one that was saved: re-pointed, the - // baseline and path below are the variable it moved to. + // path below is the variable it moved to. if (editPath === from) { - for (const { ws, s } of payloads) { - initialStates[ws] = s - existedInitially[ws] = true - } // A rename moved the item; the drawer host closes over it, but an inline one // stays mounted, so follow the new path here and tell the host about it. if (savedPath && savedPath !== editPath) editPath = savedPath diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index f484f94b43..040a5ad47e 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -44,6 +44,8 @@ import { runScheduleNow } from '../scheduled/utils' import { handleConfigChange } from '../utils' import { withForkConflictRetry } from '$lib/utils/forkConflict' + import { deepEqual } from 'fast-equals' + import { normalizeDraftForCompare } from '$lib/userDraft.svelte' import { useTriggerDraftSync } from '../useTriggerDraftSync.svelte' import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' @@ -620,7 +622,17 @@ deploymentLoading = true const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!) if (isSaved) { - draftSync.discard(previousPath, scheduleCfg) + // Drop the local draft only while the form still holds what was saved: it + // stays editable during the request, and an edit made then is a change on + // top of the save, which the reset would silently swallow. + if ( + deepEqual( + normalizeDraftForCompare($state.snapshot(getScheduleCfg())), + normalizeDraftForCompare(scheduleCfg) + ) + ) { + draftSync.discard(previousPath, scheduleCfg) + } onUpdate?.(scheduleCfg.path, previousPath, previousWs) drawer?.closeDrawer() }