feat: raw-app drafts carry a fork base, so behind means base != head for them too

The raw-app bundle never carried the version it forked from, which left raw apps
on the timestamp check that self-heals as you type, and the header's deploy guard
read a version prop nothing set, so deploying over a newer version never asked.
The route now stamps parent_version into the bundle (the draft's own base when it
has one, else the head), the server derives draft.base from it, the stale prompt
compares it to the head and links to the diff, and the editor threads it to the
header so the deploy guard confirms. A deploy re-pins the base to the version it
wrote.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-11 11:04:20 +02:00
co-authored by Claude Fable 5.1
parent 7ad7312cd0
commit 5de97332d3
4 changed files with 64 additions and 11 deletions
@@ -102,8 +102,9 @@
| undefined
diffDrawer?: DiffDrawer | undefined
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
/** Fired after a successful deploy; the session preview reloads on it. */
onDeploy?: (e: { path: string }) => void
/** Fired after a successful deploy; the session preview reloads on it and
* the route re-pins the draft's fork base to the version just written. */
onDeploy?: (e: { path: string; version?: number }) => void
/** Initial collapsed state for the file/runnable sidebar. The user's
* toggled preference is persisted under `sidebarStorageKey`; this prop
* only seeds the very first open. */
@@ -131,6 +132,10 @@
pendingDraftPath?: string | undefined
// Threaded to the AutosaveIndicator's "Reset to deployed" button.
onResetToDeployed?: () => void | Promise<void>
/** The app_version the draft forked from, for the deploy-time "new version
* deployed" guard: deploying is refused with a confirmation while it is not
* the head. Undefined for a draft-only app. */
version?: number | undefined
// See ScriptBuilderProps — same indicator semantics.
loadedFromDraft?: boolean
othersDraftsCount?: number
@@ -202,9 +207,9 @@
onScreenshotRequester = undefined,
onRestore,
onSavedNewAppPath,
condensedHeader = false
condensedHeader = false,
version = undefined
}: Props = $props()
export const version: number | undefined = undefined
// Workspace this editor operates on: the session's acting workspace when
// embedded in a session preview (autosaveWorkspace), else the navigation
@@ -248,6 +253,12 @@
// so the preview just opens the app.
setOpenInSessionHandoff({ source: () => sessionOpen })
let header: RawAppEditorHeader | undefined = $state(undefined)
/** The Deployed↔Current diff, for the route's stale-draft prompt. */
export function openDiffDrawer() {
return header?.openDiffDrawer()
}
/** Hand this app off to a fresh AI session, seeding `seedPrompt` and sending
* it on arrival. Exposed for the template picker's "Start in AI session": the
* route owns the prompt, but the draft persistence the preview depends on
@@ -2285,11 +2296,13 @@
/>
<div bind:clientWidth={rootWidth} class="max-h-full overflow-hidden h-full min-h-0 flex flex-col">
<RawAppEditorHeader
bind:this={header}
bind:jobs
bind:jobsById
bind:savedApp
bind:summary
bind:pendingDraftPath
{version}
{onRestore}
{onSavedNewAppPath}
{policy}
@@ -146,8 +146,9 @@
* falls back to `$workspaceStore`/`liveEditorDraftStoragePath`. */
autosaveWorkspace?: string
autosavePath?: string
// Fired after a successful deploy; lets the session preview reload.
onDeploy?: (e: { path: string }) => void
// Fired after a successful deploy; lets the session preview reload. Carries
// the version just written so the route can re-pin the draft's fork base.
onDeploy?: (e: { path: string; version?: number }) => void
/** Surfaces the user-typed path (`newEditedPath`) up to the route
* when (and only when) it differs from the deployed/seeded
* `savedApp.path`. The route writes it into the autosaved raw-app
@@ -431,7 +432,7 @@
}
}
async function openDiffDrawer() {
export async function openDiffDrawer() {
if (!savedApp) {
return
}
@@ -513,7 +514,7 @@
if (appPath !== npath) {
onSavedNewAppPath?.(npath)
}
onDeploy?.({ path: npath })
onDeploy?.({ path: npath, version })
}
async function setPublishState(message?: string) {
@@ -16,6 +16,9 @@ export type RawAppDraft = {
// friendly name (they read `value->>'draft_path'`) — and so editing the path
// in the editor changes the persisted draft and triggers an autosave.
draft_path?: string
// The app_version the draft forked from. The server derives `draft.base` from
// it, which is what tells a draft that is behind the deployed head.
parent_version?: number
}
// The shape a raw-app cell's store (`RawAppRuntimeValue` in
@@ -30,6 +33,7 @@ export type RuntimeRawApp = {
policy: any
custom_path?: string
draft_path?: string
parent_version?: number
}
// Strip runtime-only metadata (just `path`, the storage key) when persisting
@@ -43,7 +47,8 @@ export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft {
data: raw.data,
policy: raw.policy,
custom_path: raw.custom_path,
draft_path: raw.draft_path
draft_path: raw.draft_path,
parent_version: raw.parent_version
}
}
@@ -58,6 +63,7 @@ export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft):
data: dv.data,
policy: dv.policy ?? raw.policy,
custom_path: dv.custom_path ?? raw.custom_path,
draft_path: dv.draft_path ?? raw.draft_path
draft_path: dv.draft_path ?? raw.draft_path,
parent_version: dv.parent_version ?? raw.parent_version
}
}
@@ -122,6 +122,7 @@
summary,
policy,
custom_path: savedApp?.custom_path,
parent_version: parentVersion,
// Persist the typed path as `draft_path` only when it actually differs
// from the current path — a `draft_path` equal to the baseline is a
// no-op that would block the draft from deduping against the deployed
@@ -165,6 +166,13 @@
let othersModalOpen = $state(false)
let draftSavedAt = $state<string | undefined>(undefined)
let deployedAt = $state<string | undefined>(undefined)
// The app_version the draft forked from, and the deployed head, for the exact
// staleness check (vs the drifting timestamps). `parentVersion` is what the
// bundle carries: an own draft keeps the version it forked from; a fresh
// checkout forks from the head.
let parentVersion = $state<number | undefined>(undefined)
let draftBaseVersion = $state<string | undefined>(undefined)
let deployedHeadVersion = $state<string | undefined>(undefined)
async function loadApp(opts: { getDraft?: boolean } = {}): Promise<void> {
const getDraft = opts.getDraft ?? true
const tok = ++loadAppToken
@@ -185,6 +193,9 @@
loadedFromDraft = false
draftSavedAt = undefined
deployedAt = undefined
parentVersion = undefined
draftBaseVersion = undefined
deployedHeadVersion = undefined
// `labels` is route-level state; reset it too so a fresh draft doesn't
// inherit (and then deploy) the previously-opened app's labels. The
// import branch re-seeds it via extractRawApp below.
@@ -301,6 +312,15 @@
// See /apps/edit's loader.
draftSavedAt = backendApp.draft_saved_at as string | undefined
deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined)
// Head = the last entry of the deployed `versions`. The base the bundle
// carries is the draft's own when it has one; a draft that predates the
// base (or a fresh checkout) forks from the head from here on.
const versions = backendApp.versions as number[] | undefined
const headVersion =
backendApp.no_deployed || !Array.isArray(versions) ? undefined : versions[versions.length - 1]
deployedHeadVersion = headVersion != null ? String(headVersion) : undefined
draftBaseVersion = backendApp.draft_base as string | undefined
parentVersion = hasOwnDraft && draftBaseVersion != null ? Number(draftBaseVersion) : headVersion
// Deployed baseline for the autosave `discardIf`, captured BEFORE the swap
// below mutates `backendApp`. Mirrors the bundle `$effect`'s shape (minus
// the edit-only `draft_path`) so an unedited draft compares equal.
@@ -314,7 +334,8 @@
data: extractDataConfig(backendApp.value) ?? { ...DEFAULT_DATA },
summary: backendApp.summary ?? '',
policy: backendApp.policy,
custom_path: backendApp.custom_path
custom_path: backendApp.custom_path,
parent_version: parentVersion
})
) as RawAppDraft)
// The raw-app autosave stores a flat `RawAppDraft`, but this loader (and
@@ -549,6 +570,9 @@
bind:othersModalOpen
{draftSavedAt}
{deployedAt}
{draftBaseVersion}
{deployedHeadVersion}
onViewDiff={() => rawAppEditor?.openDiffDrawer()}
onLoadLatestDeploy={async () => {
// stopSync-bracketed; see /scripts/edit's restoreDeployed for the race.
if (!$workspaceStore) return
@@ -587,6 +611,15 @@
bind:savedApp
{diffDrawer}
newApp={isNewApp}
version={parentVersion}
onDeploy={({ version }) => {
// The version just written is the new head, and the base the next
// autosave should carry.
if (version != null) {
parentVersion = version
deployedHeadVersion = String(version)
}
}}
onResetToDeployed={reloadDeployed}
{loadedFromDraft}
othersDraftsCount={otherDraftsUsers.length}