feat(drafts): autosave-indicator popover with Reset-to-deployed action

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.
This commit is contained in:
Diego Imbert
2026-06-08 01:26:30 +02:00
parent c0bf4539bb
commit 22f2bae8c7
14 changed files with 175 additions and 22 deletions
@@ -1,14 +1,39 @@
<script lang="ts">
import { untrack } from 'svelte'
import { CloudCheck, RefreshCcw } from 'lucide-svelte'
import { tick, untrack } from 'svelte'
import { CloudCheck, RefreshCcw, RotateCcw } from 'lucide-svelte'
import type { UserDraftItemKind } from '$lib/gen'
import { UserDraftDbSyncer, type UserDraftSyncState } from '$lib/userDraftDbSyncer.svelte'
import { UserDraft } from '$lib/userDraft.svelte'
import { sendUserToast } from '$lib/toast'
import Popover from './meltComponents/Popover.svelte'
import Button from './common/button/Button.svelte'
let {
workspace,
itemKind,
path
}: { workspace: string; itemKind: UserDraftItemKind; path: string } = $props()
path,
// Reactive — when true, the indicator's popover hides "Reset to
// deployed" because there's nothing to fall back to (the editor
// is on a per-user draft at a path with no deployed row). Routes
// thread their own `isNewX` / `savedX.no_deployed` here.
draftOnly = false,
// Route-specific reset logic. The popover button stops sync,
// fires `value: null` at the syncer, awaits this, then restarts
// sync after two ticks — mirrors `notifyDraftLoaded`'s "Reset to
// deployed" toast action so the discard sticks.
onResetToDeployed
}: {
workspace: string
itemKind: UserDraftItemKind
path: string
draftOnly?: boolean
onResetToDeployed?: () => void | Promise<void>
} = $props()
// `UserDraft.has` reads `entry.state.val` (a $state), so the $derived
// re-runs when the in-memory draft appears / disappears — flips on
// after the first edit and back off after a successful reset.
const hasDraft = $derived(UserDraft.has(itemKind, path, { workspace }))
// Recompute the handle when the target draft changes; its `.state` getter
// is itself reactive to the autosave pipeline, so `syncState` tracks both.
@@ -55,15 +80,77 @@
const label = $derived(
syncState === 'saving' || syncState === 'pending' ? 'Saving...' : savedVisible ? 'Saved' : ''
)
const showResetAction = $derived(!draftOnly && hasDraft && !!onResetToDeployed)
let popoverOpen = $state(false)
let resetting = $state(false)
async function resetToDeployed() {
if (!onResetToDeployed || resetting) return
resetting = true
// Mirror the `notifyDraftLoaded` toast action so the discard
// sticks: suspend sync, POST the explicit delete, run the
// route's reload, then restart sync two ticks past the
// deployed-seed write.
UserDraft.stopSync(itemKind, path, { workspace })
UserDraftDbSyncer.save({ workspace, itemKind, path, value: null }).catch((e) =>
console.error('Reset to deployed: draft delete failed', e)
)
try {
await onResetToDeployed()
} catch (e: any) {
sendUserToast(`Could not reset to deployed: ${e?.body ?? e}`, true)
} finally {
await tick()
await tick()
UserDraft.restartSync(itemKind, path, { workspace })
resetting = false
popoverOpen = false
}
}
</script>
<div class="flex items-center gap-1.5 text-primary min-w-[4.2rem]">
{#if syncState === 'saving' || syncState === 'pending'}
<RefreshCcw size={14} class="animate-spin" />
{:else}
<CloudCheck size={16} />
{/if}
{#if label}
<span class="text-secondary text-2xs">{label}</span>
{/if}
</div>
<Popover
bind:isOpen={popoverOpen}
placement="bottom-end"
contentClasses="p-3 max-w-xs"
usePointerDownOutside
closeOnOutsideClick
>
{#snippet trigger()}
<div
class="flex items-center gap-1.5 text-primary min-w-[4.2rem] rounded-md px-1 py-0.5 hover:bg-surface-hover cursor-pointer"
aria-label="Autosave status"
>
{#if syncState === 'saving' || syncState === 'pending'}
<RefreshCcw size={14} class="animate-spin" />
{:else}
<CloudCheck size={16} />
{/if}
{#if label}
<span class="text-secondary text-2xs">{label}</span>
{/if}
</div>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-3 text-sm">
<p class="text-secondary leading-snug">
All changes are saved as a draft on the server. The draft is per-user — your teammates'
editors keep their own.
</p>
{#if showResetAction}
<Button
variant="default"
size="xs"
loading={resetting}
startIcon={{ icon: RotateCcw }}
on:click={() => void resetToDeployed()}
>
Reset to deployed
</Button>
{/if}
</div>
{/snippet}
</Popover>
@@ -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}
</div>
@@ -1088,6 +1091,7 @@
{@render previewButtons()}
{/if}
<DeployButton
on:save={async ({ detail }) => await handleSaveFlow(detail)}
{loading}
@@ -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}
<AutosaveIndicator workspace={$workspaceStore} itemKind="script" path={userDraftPath} />
<AutosaveIndicator
workspace={$workspaceStore}
itemKind="script"
path={userDraftPath}
draftOnly={(savedScript as any)?.no_deployed === true}
{onResetToDeployed}
/>
{/if}
</div>
@@ -1947,6 +1954,7 @@
{@render settingsButton()}
{/if}
<DeployButton
loading={!fullyLoaded}
{loadingSave}
@@ -79,7 +79,8 @@
gotoFn = (path: string, opt?: Record<string, any>) => 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}
@@ -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<void>
}
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 @@
</div>
{#if $workspaceStore}
<div class="ml-4">
<AutosaveIndicator workspace={$workspaceStore} itemKind="app" path={userDraftPath} />
<AutosaveIndicator
workspace={$workspaceStore}
itemKind="app"
path={userDraftPath}
draftOnly={newApp}
{onResetToDeployed}
/>
</div>
{/if}
</div>
@@ -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<void>
}
export type App = {
@@ -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<void>
}
@@ -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<void>
}
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}
@@ -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<void>
}
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}
</div>
@@ -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<void>
}
@@ -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++
}}
/>
</div>
{/if}
@@ -559,6 +559,10 @@
bind:savedApp
{diffDrawer}
newApp={isNewApp}
onResetToDeployed={async () => {
draftHandle.setDraftAndMeta(undefined, {})
await loadApp({ getDraft: false })
}}
/>
</div>
{/key}
@@ -449,6 +449,10 @@
onHistoryRestore={() => {
loadFlow()
}}
onResetToDeployed={async () => {
flowHandle.setDraftAndMeta(undefined, {})
await loadFlow({ getDraft: false })
}}
onNavigate={(item) => goto(editPathFor(item))}
{flowStore}
{flowStateStore}
@@ -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) {