From 22f2bae8c7b9edcdab10fa0d0e3674a9c3fdb8f9 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 8 Jun 2026 01:26:30 +0200 Subject: [PATCH] feat(drafts): autosave-indicator popover with Reset-to-deployed action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click the cloud icon → popover with "All changes are saved as a draft on the server. The draft is per-user — your teammates' editors keep their own." When the editor isn't on a draft-only path AND the user has a draft (UserDraft.has returns true), a "Reset to deployed" button mirrors the load-time toast action — stops sync, POSTs `value: null`, runs the route's reload-without-draft callback, restarts sync past two ticks so the deployed-seed write doesn't resurrect the draft. Threaded `onResetToDeployed` from each route down to its builder (ScriptBuilder / FlowBuilder / AppEditorHeader / RawAppEditorHeader) and into the indicator. `draftOnly` is wired from `savedScript.no_deployed` / `newFlow` / `newApp` so the action hides where there's nothing to fall back to. The indicator's trigger now has a hover affordance + matches Portal's default target ('body') via Modal2's earlier fix. --- .../lib/components/AutosaveIndicator.svelte | 115 +++++++++++++++--- .../src/lib/components/FlowBuilder.svelte | 6 +- .../src/lib/components/ScriptBuilder.svelte | 12 +- .../components/apps/editor/AppEditor.svelte | 4 +- .../apps/editor/AppEditorHeader.svelte | 14 ++- frontend/src/lib/components/apps/types.ts | 4 + frontend/src/lib/components/flow_builder.ts | 3 + .../components/raw_apps/RawAppEditor.svelte | 8 +- .../raw_apps/RawAppEditorHeader.svelte | 8 +- frontend/src/lib/components/script_builder.ts | 6 + .../(logged)/apps/edit/[...path]/+page.svelte | 5 + .../apps_raw/edit/[...path]/+page.svelte | 4 + .../flows/edit/[...path]/+page.svelte | 4 + .../scripts/edit/[...path]/+page.svelte | 4 + 14 files changed, 175 insertions(+), 22 deletions(-) diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte index 5314caa0c9..d822d87ef5 100644 --- a/frontend/src/lib/components/AutosaveIndicator.svelte +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -1,14 +1,39 @@ -
- {#if syncState === 'saving' || syncState === 'pending'} - - {:else} - - {/if} - {#if label} - {label} - {/if} -
+ + {#snippet trigger()} +
+ {#if syncState === 'saving' || syncState === 'pending'} + + {:else} + + {/if} + {#if label} + {label} + {/if} +
+ {/snippet} + + {#snippet content()} +
+

+ All changes are saved as a draft on the server. The draft is per-user — your teammates' + editors keep their own. +

+ {#if showResetAction} + + {/if} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 97bc94395c..fdc6f1de3d 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -122,7 +122,8 @@ onDeployError, onDetails, onHistoryRestore, - onNavigate + onNavigate, + onResetToDeployed }: FlowBuilderProps = $props() let initialPathStore = writable(initialPath) @@ -1054,6 +1055,8 @@ workspace={$workspaceStore} itemKind="flow" path={liveEditorDraftStoragePath} + draftOnly={newFlow} + {onResetToDeployed} /> {/if} @@ -1088,6 +1091,7 @@ {@render previewButtons()} {/if} + await handleSaveFlow(detail)} {loading} diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 01bd6a6b40..3d06be2aa0 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -127,7 +127,8 @@ onNavigate, disableAi, initialTestPanelCollapsed = false, - initialPathChosen = false + initialPathChosen = false, + onResetToDeployed }: ScriptBuilderProps = $props() export function getInitialAndModifiedValues(): SavedAndModifiedValue { @@ -1866,7 +1867,13 @@ /> {/if} {#if $workspaceStore} - + {/if} @@ -1947,6 +1954,7 @@ {@render settingsButton()} {/if} + ) => window.history.pushState(null, '', path), onSavedNewAppPath, onNavigate, - initialRevs + initialRevs, + onResetToDeployed }: AppEditorProps = $props() migrateApp(untrack(() => app)) @@ -904,6 +905,7 @@ {newPath} {newApp} userDraftPath={appDraftPath} + {onResetToDeployed} on:restore {policy} {fromHub} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 6322083976..888cc10fc5 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -106,6 +106,9 @@ onHideLeftPanel?: () => void onHideBottomPanel?: () => void onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + onResetToDeployed?: () => void | Promise } let { @@ -127,7 +130,8 @@ onHideLeftPanel, onHideRightPanel, onHideBottomPanel, - onNavigate = undefined + onNavigate = undefined, + onResetToDeployed }: Props = $props() /** Mirror of the path the user is editing in the pen popover. Initialized @@ -940,7 +944,13 @@ {#if $workspaceStore}
- +
{/if} diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 5ff52d6826..13e018d8e0 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -173,6 +173,10 @@ export interface AppEditorProps { * drift — `previousMeta` would be empty and the modal wouldn't fire. */ initialRevs?: import('$lib/userDraft.svelte').UserDraftMeta + // Threaded through `AppEditorHeader` to the `AutosaveIndicator` + // popover so its "Reset to deployed" button can do the same thing + // the load-time toast offers. + onResetToDeployed?: () => void | Promise } export type App = { diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index eb76d2e864..593de02f2d 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -38,4 +38,7 @@ export type FlowBuilderProps = { onDetails?: ({ path }: { path: string }) => void onHistoryRestore?: () => void onNavigate?: (item: WorkspaceItem) => void + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + onResetToDeployed?: () => void | Promise } diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 62f4a71856..ee5f3c9479 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -92,6 +92,10 @@ * raw-app value so the home-page row can render the friendly name * instead of the URL's autogenerated `draft_{uuid}` slot. */ pendingDraftPath?: string | undefined + // Threaded through `RawAppEditorHeader` to the `AutosaveIndicator` + // popover so its "Reset to deployed" button can do the same thing + // the load-time toast offers. + onResetToDeployed?: () => void | Promise } let { @@ -111,7 +115,8 @@ sidebarStorageKey = 'raw-app-sidebar-collapsed', liveEditorDraftStoragePath = undefined, defaultSplitWithPreview = true, - pendingDraftPath = $bindable(undefined) + pendingDraftPath = $bindable(undefined), + onResetToDeployed }: Props = $props() export const version: number | undefined = undefined @@ -1379,6 +1384,7 @@ {getBundle} {onNavigate} {onDeploy} + {onResetToDeployed} canUndo={historyManager.canUndo} canRedo={historyManager.canRedo} onUndo={handleUndo} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 6f28e3295b..4772021534 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -133,6 +133,9 @@ * draft as `draft_path` so the home-page row can render the * friendly name instead of the URL's autogenerated draft slot. */ pendingDraftPath?: string | undefined + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + onResetToDeployed?: () => void | Promise } let { @@ -160,7 +163,8 @@ onNavigate = undefined, liveEditorDraftStoragePath = undefined, onDeploy = undefined, - pendingDraftPath = $bindable(undefined) + pendingDraftPath = $bindable(undefined), + onResetToDeployed }: Props = $props() $effect(() => { @@ -725,6 +729,8 @@ workspace={$workspaceStore} itemKind="raw_app" path={liveEditorDraftStoragePath} + draftOnly={newApp} + {onResetToDeployed} /> {/if} diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index 79dfd9db89..b039b8062e 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -64,4 +64,10 @@ export interface ScriptBuilderProps { // overwrite it. Used by the session preview, which opens AI-created scripts // as new but with a path the AI already assigned. initialPathChosen?: boolean + // Threaded to the `AutosaveIndicator` popover so its "Reset to + // deployed" button can do the same thing the load-time toast offers. + // Routes pass their own re-load-without-draft callback here; omit on + // callers (session preview, embedded SDK) that shouldn't surface the + // action at all. + onResetToDeployed?: () => void | Promise } 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 bbff2a42de..6acd699e0e 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -355,6 +355,11 @@ initialRevs={currentRevs} replaceStateFn={(path) => replaceState(path, page.state)} gotoFn={(path, opt) => goto(path, opt)} + onResetToDeployed={async () => { + UserDraft.remove('app', path) + await loadApp({ getDraft: false }) + redraw++ + }} /> {/if} 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 4f7562e564..0d18b25e71 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 @@ -559,6 +559,10 @@ bind:savedApp {diffDrawer} newApp={isNewApp} + onResetToDeployed={async () => { + draftHandle.setDraftAndMeta(undefined, {}) + await loadApp({ getDraft: false }) + }} /> {/key} 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 a0c1a9de56..bc26bf1ec7 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -449,6 +449,10 @@ onHistoryRestore={() => { loadFlow() }} + onResetToDeployed={async () => { + flowHandle.setDraftAndMeta(undefined, {}) + await loadFlow({ getDraft: false }) + }} onNavigate={(item) => goto(editPathFor(item))} {flowStore} {flowStateStore} 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 82612b3fca..e131bd54e2 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -395,6 +395,10 @@ {diffDrawer} {savedPrimarySchedule} searchParams={page.url.searchParams} + onResetToDeployed={async () => { + scriptHandle.setDraftAndMeta(undefined, {}) + await loadScript({ getDraft: false }) + }} onDeploy={(e) => { // "Deploy & Stay here" / lib: stay on the editor (just confirm). if (e.stay) {