feat(frontend): wire script editor to UserDraft

The script editor's top-level state now lives in UserDraft.use(), keyed
on the route's path (page.params.path on /scripts/edit, '' on /scripts/add).
Deep edits inside ScriptBuilder persist automatically; deploy and draft
restore now call UserDraft.remove to clear the local autosave alongside
the backend draft.

Replaces the URL-hash autosave that ScriptBuilder used to write via
replaceStateFn — that prop is now gone, the encodeScriptState debounce
is gone, and Triggers no longer takes a saveSessionDraft callback.
Viewing a specific historical hash (?hash=...) is kept draft-free by
passing '' as the path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-05-11 14:43:49 +02:00
parent b56a189713
commit da692257da
4 changed files with 135 additions and 181 deletions
@@ -4,7 +4,6 @@
const bubble = createBubbler()
import {
DraftService,
type NewScript,
ScriptService,
type NewScriptWithDraft,
type Script,
@@ -35,7 +34,6 @@
cleanValueProperties,
emptySchema,
emptyString,
encodeState,
generateRandomString,
orderedJsonStringify,
readFieldsRecursively,
@@ -141,7 +139,6 @@
savedScript = $bindable(undefined),
searchParams = new URLSearchParams(),
disableHistoryChange = false,
replaceStateFn = (url) => window.history.replaceState(null, '', url),
customUi = {},
savedPrimarySchedule = undefined,
functionExports = undefined,
@@ -288,15 +285,11 @@
// Add triggers context store
const triggersState = $state(
new Triggers(
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(script.draft_triggers ?? [])
],
undefined,
saveSessionDraft
)
new Triggers([
{ type: 'webhook', path: '', isDraft: false },
{ type: 'default_email', path: '', isDraft: false },
...(script.draft_triggers ?? [])
])
)
const captureOn = writable<boolean | undefined>(undefined)
@@ -360,28 +353,6 @@
let loadingSave = $state(false)
let loadingDraft = $state(false)
let timeout2: number | undefined = undefined
function encodeScriptState(script: NewScript) {
untrack(() => timeout2 && clearTimeout(timeout2))
timeout2 = setTimeout(() => {
replaceStateFn(
'#' +
encodeState({
...script,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
})
)
}, 500)
}
let timeout: number | undefined = undefined
function saveSessionDraft() {
timeout && clearTimeout(timeout)
timeout = setTimeout(() => {
encodeScriptState(script)
}, 500)
}
if (script.content == '') {
if (template === 'wac_python') {
script.modules = {
@@ -1059,7 +1030,6 @@
})
$effect(() => {
readFieldsRecursively(script)
!disableHistoryChange && encodeScriptState(script)
})
loadWorkerTags()
@@ -31,7 +31,6 @@ export interface ScriptBuilderProps {
savedScript?: NewScriptWithDraftAndDraftTriggers | undefined
searchParams?: URLSearchParams
disableHistoryChange?: boolean
replaceStateFn?: (url: string) => void
customUi?: ScriptBuilderWhitelabelCustomUi
savedPrimarySchedule?: ScheduleTrigger | undefined
functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined
@@ -7,7 +7,6 @@
import type { Schema } from '$lib/common'
import { decodeState, emptySchema, emptyString, sendUserToast } from '$lib/utils'
import { goto } from '$lib/navigation'
import { replaceState } from '$app/navigation'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import { replaceScriptPlaceholderWithItsValues } from '$lib/hub'
import type { Trigger } from '$lib/components/triggers/utils'
@@ -16,6 +15,7 @@
import ScriptEditorSkeleton from '$lib/components/ScriptEditorSkeleton.svelte'
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
import { isWorkflowAsCode } from '$lib/components/graph/wacToFlow'
import { UserDraft } from '$lib/userDraft.svelte'
type Script = NewScript & {
draft_triggers?: Trigger[]
@@ -39,20 +39,8 @@
const path = page.url.searchParams.get('path')
const initialState = page.url.hash != '' ? page.url.hash.slice(1) : undefined
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
function decodeStateAndHandleError(state) {
try {
const decoded = decodeState(state)
return decoded
} catch (e) {
console.error('Error decoding state', e)
return defaultScript()
}
}
function defaultScript(): Script {
return {
hash: '',
@@ -63,20 +51,22 @@
schema: schema,
is_template: false,
extra_perms: {},
language: (wacParam === 'python' ? 'python3' : wacParam === 'typescript' ? 'bun' : null) ?? collabLang ?? ($defaultScripts?.order?.filter(
(x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x)
)?.[0] ?? 'bun') as ScriptLang,
language:
(wacParam === 'python' ? 'python3' : wacParam === 'typescript' ? 'bun' : null) ??
collabLang ??
(($defaultScripts?.order?.filter(
(x) => $defaultScripts?.hidden == undefined || !$defaultScripts.hidden.includes(x)
)?.[0] ?? 'bun') as ScriptLang),
kind: 'script'
}
}
let script: Script | undefined = $state(
templatePath || hubPath
? undefined
: !path && initialState != undefined
? decodeStateAndHandleError(initialState)
: defaultScript()
)
// New script: keyed on '' (in-memory only — empty paths bypass localStorage).
// templatePath/hubPath/import flows replace the value before render, so
// defaultValue is left undefined for those to avoid flashing a blank editor.
const scriptHandle = UserDraft.use<Script>('script', '', {
defaultValue: templatePath || hubPath ? undefined : defaultScript()
})
async function loadTemplate(): Promise<void> {
if (templatePath) {
@@ -85,7 +75,7 @@
workspace: $workspaceStore!,
path: templatePath
})
script = {
scriptHandle.draft = {
...defaultScript(),
summary: !emptyString(template.summary) ? `Copy of ${template.summary}` : '',
description: template.description,
@@ -95,7 +85,7 @@
path: template.path + '_fork'
}
} catch (err) {
script = defaultScript()
scriptHandle.draft = defaultScript()
console.error('Error loading template', err)
sendUserToast('Error loading template: ' + err.message, true)
}
@@ -108,7 +98,7 @@
const { content, language, summary } = await ScriptService.getHubScriptByPath({
path: hubPath
})
script = {
scriptHandle.draft = {
...defaultScript(),
description: `Fork of ${hubPath}`,
content: replaceScriptPlaceholderWithItsValues(hubPath, content),
@@ -117,7 +107,7 @@
path: hubPath + '_fork'
}
} catch (err) {
script = defaultScript()
scriptHandle.draft = defaultScript()
console.error('Error loading script from hub', err)
sendUserToast('Error loading script from hub: ' + err.message, true)
}
@@ -131,7 +121,7 @@
const imported = $importScriptStore
$importScriptStore = undefined
const isWac = isWorkflowAsCode(imported.content ?? '', imported.language ?? '')
script = {
scriptHandle.draft = {
...defaultScript(),
...imported,
path: path ?? '',
@@ -139,8 +129,7 @@
extra_perms: {}
}
if (isWac) {
importedWacTemplate =
imported.language === 'python3' ? 'wac_python' : 'wac_typescript'
importedWacTemplate = imported.language === 'python3' ? 'wac_python' : 'wac_typescript'
sendUserToast('WAC script loaded from YAML/JSON')
} else {
sendUserToast('Script loaded from YAML/JSON')
@@ -154,12 +143,17 @@
})
</script>
{#if script}
{#if scriptHandle.draft}
<ScriptBuilder
{initialArgs}
bind:this={scriptBuilder}
lockedLanguage={templatePath != null || hubPath != null}
template={importedWacTemplate ?? (wacParam === 'python' ? 'wac_python' : wacParam === 'typescript' ? 'wac_typescript' : 'script')}
template={importedWacTemplate ??
(wacParam === 'python'
? 'wac_python'
: wacParam === 'typescript'
? 'wac_typescript'
: 'script')}
onDeploy={(e) => {
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
@@ -167,9 +161,8 @@
goto(`/scripts/edit/${e.path}`)
}}
searchParams={page.url.searchParams}
bind:script
bind:script={scriptHandle.draft}
{showMeta}
replaceStateFn={(path) => replaceState(path, page.state)}
>
<UnsavedConfirmationModal
getInitialAndModifiedValues={scriptBuilder?.getInitialAndModifiedValues}
@@ -3,9 +3,8 @@
import { initialArgsStore, workspaceStore } from '$lib/stores'
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import { decodeState, cleanValueProperties, orderedJsonStringify } from '$lib/utils'
import { cleanValueProperties, orderedJsonStringify } from '$lib/utils'
import { goto } from '$lib/navigation'
import { replaceState } from '$app/navigation'
import { sendUserToast } from '$lib/toast'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
@@ -14,8 +13,10 @@
import { get } from 'svelte/store'
import { untrack } from 'svelte'
import { page } from '$app/state'
import { UserDraft } from '$lib/userDraft.svelte'
type EditableScript = NewScript & { draft_triggers?: Trigger[] }
let initialState = window.location.hash != '' ? window.location.hash.slice(1) : undefined
let initialArgs = get(initialArgsStore) ?? {}
if (get(initialArgsStore)) $initialArgsStore = undefined
@@ -23,16 +24,15 @@
let hash = page.url.searchParams.get('hash') ?? undefined
let scriptLoadedFromUrl = initialState != undefined ? decodeState(initialState) : undefined
let script: (NewScript & { draft_triggers?: Trigger[] }) | undefined = $state(undefined)
// When viewing a specific historical hash we don't want to load or write a
// local draft — that view is read-only relative to drafts.
const draftPath = hash ? '' : (page.params.path ?? '')
const scriptHandle = UserDraft.use<EditableScript>('script', draftPath)
let initialPath: string = $state('')
let scriptBuilder: ScriptBuilder | undefined = $state(undefined)
let reloadAction: () => Promise<void> = async () => {}
let savedScript: NewScriptWithDraft | undefined = $state(undefined)
let fullyLoaded = $state(false)
@@ -40,28 +40,87 @@
async function loadScript(): Promise<void> {
fullyLoaded = false
if (scriptLoadedFromUrl != undefined && scriptLoadedFromUrl.path == page.params.path) {
script = scriptLoadedFromUrl
reloadAction = async () => {
scriptLoadedFromUrl = undefined
goto(`/scripts/edit/${script!.path}`)
loadScript()
}
if (hash) {
const scriptByHash = await ScriptService.getScriptByHash({
workspace: $workspaceStore!,
hash
})
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
scriptHandle.draft = { ...scriptByHash, parent_hash: hash, lock: undefined }
} else {
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
savedScript = structuredClone($state.snapshot(scriptWithDraft))
async function compareAutosave() {
savedScript = await ScriptService.getScriptByPathWithDraft({
workspace: $workspaceStore!,
path: script!.path
})
const localDraft = scriptHandle.draft
const backendDraft = scriptWithDraft.draft
? ({ ...scriptWithDraft.draft } as EditableScript)
: undefined
const draftOrDeployed = cleanValueProperties(savedScript?.draft || savedScript)
const urlScript = cleanValueProperties(scriptLoadedFromUrl)
if (orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(urlScript)) {
reloadAction()
if (localDraft != undefined) {
// Local autosave wins; offer a diff against the latest saved
// version (backend draft if any, otherwise deployed) so the
// user can discard it.
const reference = backendDraft ?? scriptWithDraft
const referenceClean = cleanValueProperties(reference)
const localClean = cleanValueProperties(localDraft)
if (orderedJsonStringify(referenceClean) === orderedJsonStringify(localClean)) {
// Local matches the saved version — silently drop it and use the saved one.
scriptHandle.draft = backendDraft ?? (scriptWithDraft as EditableScript)
} else {
sendUserToast('Script loaded from latest autosave stored in the URL', false, [
sendUserToast('Script loaded from local autosave', false, [
{
label: 'Discard browser stored autosave and reload',
label: 'Discard local autosave',
callback: () => {
scriptHandle.draft = backendDraft ?? (scriptWithDraft as EditableScript)
}
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: referenceClean,
current: localClean,
title: `${backendDraft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
button: {
text: 'Discard autosave',
onClick: () => {
scriptHandle.draft = backendDraft ?? (scriptWithDraft as EditableScript)
}
}
})
}
}
])
}
} else if (backendDraft) {
scriptHandle.draft = backendDraft
if (scriptHandle.draft?.['primary_schedule']) {
savedPrimarySchedule = scriptHandle.draft['primary_schedule']
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
}
scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers)
if (!scriptWithDraft.draft_only) {
const reloadAction = async () => {
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: scriptHandle.draft!.path
})
UserDraft.remove('script', draftPath)
goto(`/scripts/edit/${scriptHandle.draft!.path}`)
loadScript()
}
const deployed = cleanValueProperties(scriptWithDraft)
const draft = cleanValueProperties(scriptHandle.draft)
sendUserToast('Script loaded from latest saved draft', false, [
{
label: 'Discard draft reset to deployed version',
callback: reloadAction
},
{
@@ -70,94 +129,29 @@
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: draftOrDeployed,
current: urlScript,
title: `${savedScript?.draft ? 'Latest saved draft' : 'Deployed'} <> Autosave`,
button: { text: 'Discard autosave', onClick: reloadAction }
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
}
compareAutosave()
} else {
if (hash) {
const scriptByHash = await ScriptService.getScriptByHash({
workspace: $workspaceStore!,
hash
})
savedScript = structuredClone($state.snapshot(scriptByHash)) as NewScriptWithDraft
script = { ...scriptByHash, parent_hash: hash, lock: undefined }
} else {
const scriptWithDraft = await ScriptService.getScriptByPathWithDraft({
workspace: $workspaceStore!,
path: page.params.path ?? ''
})
savedScript = structuredClone($state.snapshot(scriptWithDraft))
if (scriptWithDraft.draft != undefined) {
script = scriptWithDraft.draft
scriptBuilder?.setDraftTriggers(script.draft_triggers)
if (script['primary_schedule']) {
savedPrimarySchedule = script['primary_schedule']
scriptBuilder?.setPrimarySchedule(savedPrimarySchedule)
}
if (!scriptWithDraft.draft_only) {
reloadAction = async () => {
scriptLoadedFromUrl = undefined
await DraftService.deleteDraft({
workspace: $workspaceStore!,
kind: 'script',
path: script!.path
})
goto(`/scripts/edit/${script!.path}`)
loadScript()
}
const deployed = cleanValueProperties(scriptWithDraft)
const draft = cleanValueProperties(script)
sendUserToast('Script loaded from latest saved draft', false, [
{
label: 'Discard draft reset to deployed version',
callback: reloadAction
},
{
label: 'Show diff',
callback: async () => {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
original: deployed,
current: draft,
title: 'Deployed <> Draft',
button: { text: 'Discard draft', onClick: reloadAction }
})
}
}
])
}
} else {
script = scriptWithDraft
}
script.parent_hash = scriptWithDraft.hash
scriptHandle.draft = scriptWithDraft as EditableScript
}
if (scriptHandle.draft) {
scriptHandle.draft.parent_hash = scriptWithDraft.hash
}
}
// hash
// ? await ScriptService.getScriptByHash({
// workspace: $workspaceStore!,
// hash: page.params.hash
// })
// : await ScriptService.getScriptByPathWithDraft({
// workspace: $workspaceStore!,
// path: $page.params.path
// })
if (script) {
initialPath = script.path
scriptBuilder?.setDraftTriggers(script.draft_triggers)
scriptBuilder?.setCode(script.content)
if (scriptHandle.draft) {
initialPath = scriptHandle.draft.path
scriptBuilder?.setDraftTriggers(scriptHandle.draft.draft_triggers)
scriptBuilder?.setCode(scriptHandle.draft.content)
if (topHash) {
script.parent_hash = topHash
scriptHandle.draft.parent_hash = topHash
}
}
fullyLoaded = true
@@ -177,8 +171,8 @@
return
}
diffDrawer?.closeDrawer()
UserDraft.remove('script', draftPath)
goto(`/scripts/edit/${savedScript.draft.path}`)
scriptLoadedFromUrl = undefined
loadScript()
}
@@ -195,18 +189,18 @@
path: savedScript.path
})
}
UserDraft.remove('script', draftPath)
goto(`/scripts/edit/${savedScript.path}`)
scriptLoadedFromUrl = undefined
loadScript()
}
</script>
<DiffDrawer bind:this={diffDrawer} {restoreDraft} {restoreDeployed} />
{#if script}
{#if scriptHandle.draft}
<ScriptBuilder
bind:this={scriptBuilder}
{initialPath}
bind:script
bind:script={scriptHandle.draft}
{fullyLoaded}
bind:savedScript
{initialArgs}
@@ -214,6 +208,7 @@
{savedPrimarySchedule}
searchParams={page.url.searchParams}
onDeploy={(e) => {
UserDraft.remove('script', draftPath)
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
onSaveInitial={(e) => {
@@ -222,9 +217,6 @@
onSeeDetails={(e) => {
goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`)
}}
replaceStateFn={(path) => {
replaceState(path, page.state)
}}
>
<UnsavedConfirmationModal
{diffDrawer}