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 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-11 15:55:26 +02:00
co-authored by Claude Fable 5.1
parent ceae36a85b
commit 504c360d66
14 changed files with 98 additions and 75 deletions
+30 -1
View File
@@ -50,6 +50,7 @@
* way to tell what their draft is being compared against. */
deployedLabel?: string
versions?: DiffVersionOption[]
onTakeLatest?: () => void | Promise<void>
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<Value | undefined>
onTakeLatest?: () => void | Promise<void>
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}
</div>
{#snippet actions()}
{#if data?.mode === 'normal' && data.onTakeLatest}
<Button unifiedSize="sm" variant="default" loading={takingLatest} onClick={takeLatest}>
Take latest, keep my edits
</Button>
{/if}
{#if data?.mode === 'normal'}
<Button
unifiedSize="sm"
@@ -123,6 +123,7 @@
* route re-seeds from `draft_path` so the topbar shows the pending name,
* this stays where the item actually is. */
userDraftPath = '',
onTakeLatest = undefined,
pathStoreInit = undefined,
newFlow,
selectedId,
@@ -1072,6 +1073,7 @@
deployed: deployedValue ?? savedFlow,
deployedLabel,
versions: await deployedVersionOptions(),
onTakeLatest,
loadVersion: async (id) => {
const v = await FlowService.getFlowVersion({
workspace: opWorkspace!,
@@ -119,6 +119,7 @@
fullyLoaded = true,
initialPath = $bindable(''),
userDraftPath = '',
onTakeLatest = undefined,
autosaveWorkspace = undefined,
autosavePath = undefined,
template = $bindable('script'),
@@ -887,6 +888,7 @@
deployed,
deployedLabel: deployedVersionLabel(deployed),
versions: await deployedVersionOptions(headHash),
onTakeLatest,
loadVersion: async (hash) => {
const v = await ScriptService.getScriptByHash({ workspace: opWorkspace!, hash })
return replaceFalseWithUndefined({
@@ -54,9 +54,6 @@
deployedBy?: string | undefined
/** Discard the draft and reload deployed (same as "Reset to deployed"). */
onLoadLatestDeploy?: () => void | Promise<void>
/** Move the draft's base to the head and keep its content (see
* StaleDraftModal). Omit where the route cannot set the base. */
onTakeLatest?: () => void | Promise<void>
/** Opens the editor's Deployed↔Current diff from the stale prompt, so the
* choice between keeping and discarding is informed. Omit where the editor
* has no diff drawer; the action is then not rendered. */
@@ -87,7 +84,6 @@
deployedBy = undefined,
onLoadLatestDeploy,
onViewDiff,
onTakeLatest,
onBeforeRelocate,
enabled = true
}: Props = $props()
@@ -185,7 +181,6 @@
{deployedBy}
{onLoadLatestDeploy}
{onViewDiff}
{onTakeLatest}
/>
{/if}
<ConfirmationModal
@@ -3,9 +3,10 @@
* The prompt for a draft that is behind: someone deployed a newer version
* of the item after the draft forked from it. Opened on every load while
* that holds (the parent computes it; see DraftEditorModals), it names the
* two versions and offers the four ways out: look at the diff, keep going,
* take the latest as the new base while keeping the edits, or drop the
* draft for the latest deploy.
* two versions and offers two ways out: look at the diff, or drop the draft
* for the latest deploy. Closing it is "keep editing". Taking the latest as
* the new base while keeping the edits lives in the diff drawer, where the
* user can see what they are taking.
*
* Open-state is bindable so the parent can dismiss programmatically
* (e.g. after the load-latest-deploy callback completes).
@@ -38,10 +39,6 @@
* to see what actually differs — and after a rename the difference is
* often only the path. Omitted where the editor has no diff drawer. */
onViewDiff?: () => void | Promise<void>
/** Moves the draft's base to the head and keeps its content: the one way
* to acknowledge the newer version without discarding edits. The route
* owns it because the base lives in a per-kind field of the value. */
onTakeLatest?: () => void | Promise<void>
}
let {
@@ -53,25 +50,10 @@
deployedHeadVersion = undefined,
deployedBy = undefined,
onLoadLatestDeploy,
onViewDiff,
onTakeLatest
onViewDiff
}: Props = $props()
let loading = $state(false)
let takingLatest = $state(false)
async function takeLatest() {
if (takingLatest || !onTakeLatest) return
takingLatest = true
try {
await onTakeLatest()
isOpen = false
} catch (e: any) {
sendUserToast(`Could not take the latest version: ${e?.body ?? e?.message ?? e}`, true)
} finally {
takingLatest = false
}
}
// Scripts are versioned by hash, the other kinds by a numeric version id;
// the diff picker renders them the same way.
@@ -142,19 +124,11 @@
{:else}
<div></div>
{/if}
<div class="flex gap-2">
<Button variant="default" unifiedSize="sm" on:click={() => (isOpen = false)}>
Keep editing my draft
</Button>
{#if onTakeLatest}
<Button variant="default" unifiedSize="sm" loading={takingLatest} on:click={takeLatest}>
Take latest, keep my edits
</Button>
{/if}
<Button variant="accent" unifiedSize="sm" {loading} on:click={loadLatestDeploy}>
Load latest deploy
</Button>
</div>
<!-- Red: this replaces the draft with the latest deploy. Closing the modal
is "keep editing", so it needs no button of its own. -->
<Button variant="accent" destructive unifiedSize="sm" {loading} on:click={loadLatestDeploy}>
Use latest
</Button>
</div>
</div>
</Modal2>
@@ -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<Value | undefined>
/** 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<void>
draft?: Value | undefined
current: Value
defaultDiffType?: 'deployed' | 'draft'
@@ -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<void>
pathStoreInit?: string | undefined
newFlow: boolean
selectedId: string | undefined
@@ -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<void>
// 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}
@@ -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<void>
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
@@ -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<void>
/**
* Workspace + path the AutosaveIndicator watches for sync state. Default
* (undefined) falls back to `$workspaceStore` / `userDraftPath` the
@@ -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({
@@ -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.
@@ -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 @@
</div>
{:else if renderEditor}
<FlowBuilder
onTakeLatest={draftBaseVersion && version != null && draftBaseVersion !== String(version)
? 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 })
}
: undefined}
onDeploy={(e) => {
// stopSync-bracketed immediate delete; see /scripts/edit's restoreDeployed.
if ($workspaceStore) {
@@ -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}