diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte index 9583075d77..42608781fd 100644 --- a/frontend/src/lib/components/AutosaveIndicator.svelte +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -120,7 +120,7 @@ {#snippet content()}
-

+

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

diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 6e409b3365..346a85e6a6 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -428,6 +428,22 @@ dispatch('change', ncode) } + /** Force-materialize the latest Monaco content into the bindable + * `code` prop right now, bypassing the trailing debounce. Use for + * explicit "save now" shortcuts (Ctrl/Cmd+S) — without this, anything + * the user typed within the last `changeTimeout` ms is still sitting + * in Monaco's buffer and downstream consumers (autosave, lint) won't + * see it. Clears the chain state so the next keystroke after this + * flush is a fresh leading fire. */ + export function flushPendingChanges(): void { + if (timeoutModel !== undefined) { + clearTimeout(timeoutModel) + timeoutModel = undefined + } + changeChainStart = undefined + updateCode() + } + export function append(code: string): void { if (editor) { const lineCount = editor.getModel()?.getLineCount() || 0 diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 3e4856f30f..c3be99558c 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -23,6 +23,7 @@ type Value } from '$lib/utils' import { sendUserToast } from '$lib/toast' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { Drawer } from '$lib/components/common' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte' @@ -253,8 +254,26 @@ let loadingSave = $state(false) - // No-op: persistence happens via the page-level UserDraft autosave. - export function saveDraft(): void {} + // Ctrl/Cmd+S forces an immediate save of whatever the page-level + // autosave has pending. Unlike ScriptBuilder we don't have a direct + // Monaco ref to flush — any focused module Monaco's pending text is + // constrained by the editor's own ~1s max-wait cap, so the flush + // here picks up whatever's already in `pendingSaveOpts`. Worst case + // the user's very last keystroke (<1s ago) isn't in this POST and + // follows in the next autosave round. + export async function saveDraft(): Promise { + if (!$workspaceStore || !liveEditorDraftStoragePath) return + try { + await UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'flow', + path: liveEditorDraftStoragePath + }) + sendUserToast('Draft saved') + } catch (e: any) { + sendUserToast(`Could not save draft: ${e?.body ?? e?.message ?? e}`, true) + } + } export function computeUnlockedSteps(flow: Flow) { return Object.fromEntries( @@ -1103,7 +1122,6 @@ {@render previewButtons()} {/if} - await handleSaveFlow(detail)} {loading} diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 1434269c5a..a6c891689a 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -79,7 +79,7 @@ import { writable } from 'svelte/store' import { defaultScriptLanguages, processLangs } from '$lib/scripts' import DefaultScripts from './DefaultScripts.svelte' - import { getContext, onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, tick, untrack } from 'svelte' import EditorHeader from './EditorHeader.svelte' import AutosaveIndicator from './AutosaveIndicator.svelte' import LabelsInput from './LabelsInput.svelte' @@ -102,6 +102,7 @@ import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' import WacExportDrawer from './scripts/WacExportDrawer.svelte' import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' let { script = $bindable(), @@ -694,8 +695,27 @@ loadingSave = false } - // No-op: persistence happens via the page-level UserDraft autosave. - function saveDraft(): void {} + // Ctrl/Cmd+S forces an immediate save of whatever the page-level + // autosave has pending. Flush Monaco first so anything typed within + // the last `changeTimeout` ms reaches the bindable before we tell + // the syncer to flush — otherwise we'd POST the pre-burst content. + // `tick()` lets the bind:code → script.content → UserDraft mirror + // chain settle before the flush call sees `pendingSaveOpts`. + async function saveDraft(): Promise { + if (!$workspaceStore || !userDraftPath) return + editor?.flushPendingChanges() + await tick() + try { + await UserDraftDbSyncer.flush({ + workspace: $workspaceStore, + itemKind: 'script', + path: userDraftPath + }) + sendUserToast('Draft saved') + } catch (e: any) { + sendUserToast(`Could not save draft: ${e?.body ?? e?.message ?? e}`, true) + } + } // Inside an AI session pane (which injects an aiChatManager via context) the // extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace @@ -1955,7 +1975,6 @@ {@render settingsButton()} {/if} - sendUserToast('Draft saved')) + .catch((err: any) => + sendUserToast(`Could not save draft: ${err?.body ?? err?.message ?? err}`, true) + ) + return + } + // Skip when typing in an input, textarea, or Monaco editor. const classes = (e.target as HTMLElement | null)?.className if ( diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index c9918bc8f7..addf55d9e3 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -391,5 +391,29 @@ export const UserDraftDbSyncer = { */ async overwrite(opts: Omit): Promise { await this.save({ ...opts, immediate: true, force: true }) + }, + + /** + * Flush whatever's queued in the autosave debouncer for this draft + * RIGHT NOW. Use for explicit "save now" shortcuts (Ctrl/Cmd+S). + * + * Re-submits the latest opts captured by `save()` (held in + * `pendingSaveOpts` until the POST lands) with `immediate: true`, + * which cancels the queued debouncer and routes through the + * coalescing runner. The returned promise resolves only after the + * POST lands, so callers can `await flush(...); show "Saved"`. + * + * No-op when nothing is pending (the most recent save already + * landed, or the editor never autosaved this entry). The caller + * should NOT assume "no pending" means "nothing to save" — Monaco + * may still have unmaterialized text in its own buffer; flush the + * editor first (`Editor.flushPendingChanges()`) and await `tick()` + * so the bind:code propagation reaches our save() before this. + */ + async flush(query: UserDraftLastSyncQuery): Promise { + const key = draftKey(query.workspace, query.itemKind, query.path) + const opts = pendingSaveOpts.get(key) + if (!opts) return + await this.save({ ...opts, immediate: true }) } }