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 })
}
}