From 504c360d66b47b2cc96a7556bac8ba8aabde99ec Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Fri, 11 Sep 2026 15:55:26 +0200 Subject: [PATCH] feat: two-action out-of-date prompt; taking the latest moves into the diff drawer Four buttons made the prompt hard to read. It keeps "See what changed" and a red "Use latest" (it replaces the draft); closing it is keeping the draft. "Take latest, keep my edits" moves to the diff drawer's header, offered only while the draft is behind, so the user takes the latest with the diff in front of them. Scripts, flows and raw apps pass the action through their diff drawer; the classic app editor has no drawer wired to the prompt and loses it. Co-Authored-By: Claude Fable 5.1 --- frontend/src/lib/components/DiffDrawer.svelte | 31 ++++++++++++- .../src/lib/components/FlowBuilder.svelte | 2 + .../src/lib/components/ScriptBuilder.svelte | 2 + .../DraftEditorModals.svelte | 5 -- .../confirmationModal/StaleDraftModal.svelte | 46 ++++--------------- frontend/src/lib/components/diff_drawer.ts | 4 ++ frontend/src/lib/components/flow_builder.ts | 3 ++ .../components/raw_apps/RawAppEditor.svelte | 7 ++- .../raw_apps/RawAppEditorHeader.svelte | 5 ++ frontend/src/lib/components/script_builder.ts | 4 ++ .../(logged)/apps/edit/[...path]/+page.svelte | 7 --- .../apps_raw/edit/[...path]/+page.svelte | 19 ++++---- .../flows/edit/[...path]/+page.svelte | 16 ++++--- .../scripts/edit/[...path]/+page.svelte | 22 +++++---- 14 files changed, 98 insertions(+), 75 deletions(-) diff --git a/frontend/src/lib/components/DiffDrawer.svelte b/frontend/src/lib/components/DiffDrawer.svelte index ea94567649..c1655da487 100644 --- a/frontend/src/lib/components/DiffDrawer.svelte +++ b/frontend/src/lib/components/DiffDrawer.svelte @@ -50,6 +50,7 @@ * way to tell what their draft is being compared against. */ deployedLabel?: string versions?: DiffVersionOption[] + onTakeLatest?: () => void | Promise draft: DiffData | undefined current: DiffData path?: string @@ -112,6 +113,18 @@ } } + let takingLatest = $state(false) + async function takeLatest() { + if (!data || data.mode !== 'normal' || !data.onTakeLatest || takingLatest) return + takingLatest = true + try { + await data.onTakeLatest() + diffViewer?.closeDrawer() + } finally { + takingLatest = false + } + } + export function setDiff( diff: | { @@ -120,6 +133,7 @@ deployedLabel?: string versions?: DiffVersionOption[] loadVersion?: (id: string) => Promise + onTakeLatest?: () => void | Promise draft?: Value | undefined current: Value defaultDiffType?: 'deployed' | 'draft' @@ -134,7 +148,16 @@ } ) { if (diff.mode === 'normal') { - const { deployed, deployedLabel, versions, loadVersion, draft, current, button } = diff + const { + deployed, + deployedLabel, + versions, + loadVersion, + onTakeLatest, + draft, + current, + button + } = diff versionLoader = loadVersion headLabel = deployedLabel selectedVersion = versions?.find((v) => v.isHead)?.id @@ -143,6 +166,7 @@ deployed: !deployed.draft_only ? prepareDiff(deployed) : undefined, deployedLabel, versions, + onTakeLatest, draft: draft ? prepareDiff(draft) : undefined, current: prepareDiff(current), path: draft?.path || deployed?.path, @@ -279,6 +303,11 @@ {/if} {#snippet actions()} + {#if data?.mode === 'normal' && data.onTakeLatest} + + {/if} {#if data?.mode === 'normal'} - {#if onTakeLatest} - - {/if} - - + + diff --git a/frontend/src/lib/components/diff_drawer.ts b/frontend/src/lib/components/diff_drawer.ts index 873a26dd41..60a125ec0b 100644 --- a/frontend/src/lib/components/diff_drawer.ts +++ b/frontend/src/lib/components/diff_drawer.ts @@ -27,6 +27,10 @@ export type DiffDrawerDiff = /** Loads one version's payload. Returning `undefined` leaves the current * comparison in place rather than blanking the diff. */ loadVersion?: (id: string) => Promise + /** Moves the draft's base to the head and keeps its content. Passed only + * while the draft is behind; rendered as a header action so the user + * takes the latest with the diff in front of them. */ + onTakeLatest?: () => void | Promise draft?: Value | undefined current: Value defaultDiffType?: 'deployed' | 'draft' diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index a439a8aba4..f9b8e64aa2 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -14,6 +14,9 @@ export type FlowBuilderProps = { * draft's `draft_path` so the topbar shows the pending name, so it can't be used * to resolve what is actually deployed. */ userDraftPath?: string + /** Moves the draft's base to the deployed head and keeps its content; offered + * in the diff drawer while the draft is behind. */ + onTakeLatest?: () => void | Promise pathStoreInit?: string | undefined newFlow: boolean selectedId: string | undefined diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 7dfb3630a0..5ddb6d1b91 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -136,6 +136,9 @@ * deployed" guard: deploying is refused with a confirmation while it is not * the head. Undefined for a draft-only app. */ version?: number | undefined + /** Moves the draft's base to the deployed head and keeps its content; + * offered in the diff drawer while the draft is behind. */ + onTakeLatest?: () => void | Promise // See ScriptBuilderProps — same indicator semantics. loadedFromDraft?: boolean othersDraftsCount?: number @@ -208,7 +211,8 @@ onRestore, onSavedNewAppPath, condensedHeader = false, - version = undefined + version = undefined, + onTakeLatest = undefined }: Props = $props() // Workspace this editor operates on: the session's acting workspace when @@ -2303,6 +2307,7 @@ bind:summary bind:pendingDraftPath {version} + {onTakeLatest} {onRestore} {onSavedNewAppPath} {policy} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index e1c38b88ac..a865b46cdc 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -109,6 +109,9 @@ } | undefined version?: number | undefined + /** Moves the draft's base to the deployed head and keeps its content; + * offered in the diff drawer while the draft is behind. */ + onTakeLatest?: () => void | Promise newApp: boolean newPath?: string /** Initial labels for the app, threaded from the loaded app data. */ @@ -179,6 +182,7 @@ diffDrawer = undefined, savedApp = $bindable(undefined), version = $bindable(undefined), + onTakeLatest = undefined, newApp, newPath = '', labels: initialLabels = undefined, @@ -445,6 +449,7 @@ mode: 'normal', deployed: deployedValue ?? stripRawAppDiffNoise(savedApp), versions: await deployedVersionOptions(), + onTakeLatest, loadVersion: async (id) => { const v = await AppService.getAppByVersion({ workspace: opWorkspace!, id: Number(id) }) // Same normalization as `syncWithDeployed`, so switching versions doesn't diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index 69916f321b..cf95dfda5b 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -36,6 +36,10 @@ export interface ScriptBuilderProps { * stop/restart pair is a no-op on a non-live entry. */ userDraftPath?: string + /** Moves the draft's base to the deployed head and keeps its content; offered + * in the diff drawer while the draft is behind. The route owns it because + * the base lives in a per-kind field of the value. */ + onTakeLatest?: () => void | Promise /** * Workspace + path the AutosaveIndicator watches for sync state. Default * (undefined) falls back to `$workspaceStore` / `userDraftPath` — the diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index 1577cf1c21..d38f45eb8e 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -465,13 +465,6 @@ {draftBaseVersion} {deployedHeadVersion} {deployedBy} - onTakeLatest={async () => { - const head = deployedHeadVersion != null ? Number(deployedHeadVersion) : undefined - if (!app?.value || head == null || !$workspaceStore) return - ;(app.value as App).parent_version = head - draftBaseVersion = String(head) - await UserDraft.forcePersist('app', path, { workspace: $workspaceStore }) - }} onLoadLatestDeploy={async () => { if (!$workspaceStore) return await runResetToDeployed({ diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 87717b405d..0eadf0746b 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -582,14 +582,6 @@ {draftBaseVersion} {deployedHeadVersion} {deployedBy} - onTakeLatest={() => { - const head = deployedHeadVersion != null ? Number(deployedHeadVersion) : undefined - if (head == null) return - // The bundle carries `parentVersion`, so this alone re-persists the draft. - parentVersion = head - if (deployedBaseline) deployedBaseline = { ...deployedBaseline, parent_version: head } - draftBaseVersion = String(head) - }} onViewDiff={() => rawAppEditor?.openDiffDrawer()} onLoadLatestDeploy={async () => { // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. @@ -630,6 +622,17 @@ {diffDrawer} newApp={isNewApp} version={parentVersion} + onTakeLatest={draftBaseVersion && + deployedHeadVersion && + draftBaseVersion !== deployedHeadVersion + ? () => { + const head = Number(deployedHeadVersion) + // The bundle carries `parentVersion`, so this alone re-persists the draft. + parentVersion = head + if (deployedBaseline) deployedBaseline = { ...deployedBaseline, parent_version: head } + draftBaseVersion = String(head) + } + : undefined} onDeploy={({ version }) => { // The version just written is the new head, and the base the next // autosave should carry. diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 8fc466af2f..2dcb3fe57a 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -528,13 +528,6 @@ {draftBaseVersion} deployedHeadVersion={version != null ? String(version) : undefined} {deployedBy} - onTakeLatest={async () => { - const head = version - if (!draftSync.draft || head == null || !$workspaceStore) return - draftSync.draft = { ...draftSync.draft, version_id: head } - draftBaseVersion = String(head) - await UserDraft.forcePersist('flow', flowDraftPath, { workspace: $workspaceStore }) - }} onViewDiff={() => flowBuilder?.openDiffDrawer()} onBeforeRelocate={() => flowBuilder?.saveDraft()} onLoadLatestDeploy={async () => { @@ -558,6 +551,15 @@ {:else if renderEditor} { + const head = version + if (!draftSync.draft || head == null || !$workspaceStore) return + draftSync.draft = { ...draftSync.draft, version_id: head } + draftBaseVersion = String(head) + await UserDraft.forcePersist('flow', flowDraftPath, { workspace: $workspaceStore }) + } + : undefined} onDeploy={(e) => { // stopSync-bracketed immediate delete; see /scripts/edit's restoreDeployed. if ($workspaceStore) { diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index 4708390cdf..37495e5c34 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -489,16 +489,6 @@ draftBaseVersion={draftBaseHash} deployedHeadVersion={deployedHeadHash} {deployedBy} - onTakeLatest={async () => { - const head = deployedHeadHash - if (!draftSync.draft || !head || !$workspaceStore) return - draftSync.draft = { ...draftSync.draft, parent_hash: head } - // The baseline mirrors the draft's base so an unedited draft still - // compares equal and the autosave can discard it. - if (deployedBaseline) deployedBaseline = { ...deployedBaseline, parent_hash: head } - draftBaseHash = head - await UserDraft.forcePersist('script', draftPath, { workspace: $workspaceStore }) - }} onViewDiff={() => scriptBuilder?.openDiffDrawer()} onBeforeRelocate={() => scriptBuilder?.saveDraft()} onLoadLatestDeploy={async () => { @@ -520,6 +510,18 @@ bind:this={scriptBuilder} {initialPath} userDraftPath={draftPath} + onTakeLatest={draftBaseHash && deployedHeadHash && draftBaseHash !== deployedHeadHash + ? async () => { + const head = deployedHeadHash + if (!draftSync.draft || !head || !$workspaceStore) return + draftSync.draft = { ...draftSync.draft, parent_hash: head } + // The baseline mirrors the draft's base so an unedited draft still + // compares equal and the autosave can discard it. + if (deployedBaseline) deployedBaseline = { ...deployedBaseline, parent_hash: head } + draftBaseHash = head + await UserDraft.forcePersist('script', draftPath, { workspace: $workspaceStore }) + } + : undefined} bind:script={draftSync.draft} template={builderTemplate} {lockedLanguage}