feat: plug global chat drafts into userdraft (#9291)

* refactor: move global chat drafts to userdraft

* feat: share script and flow drafts with editors

* feat: share trigger drafts with editors

* feat: share raw app drafts with editor

* feat: share resource drafts with editors

* docs: rename global chat drafts copy

* feat: add global chat draft discard tool

* fix: resolve global chat editor draft paths

* fix: remove editor draft path resolver

* feat: track live editor drafts in userdraft

* fix: snapshot live userdraft reads

* chore: checkpoint pending global draft changes

* fix: address global draft review issues

* fix: defer raw app draft persistence

* docs: remove pr investigation docs

* fix: persist live global draft writes
This commit is contained in:
centdix
2026-05-25 16:18:57 +02:00
committed by GitHub
parent 98bd5e7f2a
commit 1eef53170b
21 changed files with 2492 additions and 587 deletions
@@ -100,6 +100,7 @@
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
import { buildForkEditUrl } from '$lib/utils/editInFork'
import { isCloudHosted } from '$lib/cloud'
import { UserDraft } from '$lib/userDraft.svelte'
let {
initialPath = $bindable(''),
@@ -123,6 +124,7 @@
children,
loadedFromHistoryFromUrl,
noInitial = false,
liveEditorDraftStoragePath = undefined,
onSaveInitial,
onSaveDraft,
onDeploy,
@@ -588,6 +590,23 @@
const flowEditorDrawer = writable<FlowEditorDrawer | undefined>(undefined)
const history = initHistory(untrack(() => flowStore).val)
const pathStore = writable<string>(untrack(() => pathStoreInit) ?? initialPath)
$effect(() => {
if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return
const workspace = $workspaceStore
UserDraft.setLiveEditorDraft({
workspace,
itemKind: 'flow',
storagePath: liveEditorDraftStoragePath,
effectivePath: $pathStore
})
return () =>
UserDraft.clearLiveEditorDraft('flow', {
workspace,
storagePath: liveEditorDraftStoragePath
})
})
const captureOn = writable<boolean>(false)
const showCaptureHint = writable<boolean | undefined>(undefined)
const flowInputEditorStateStore = writable<FlowInputEditorState>({
@@ -42,17 +42,75 @@ vi.mock('$lib/gen', async () => {
return {
...actual,
ScriptService: wrapService(actual.ScriptService, {
existsScriptByPath: vi.fn(async () => false),
createScript: vi.fn(async () => 'created'),
getScriptByPathWithDraft: vi.fn(async () => {
throw new Error('getScriptByPathWithDraft mock not configured')
}),
listScripts: vi.fn(async () => [])
}),
FlowService: wrapService(actual.FlowService, {
existsFlowByPath: vi.fn(async () => false)
existsFlowByPath: vi.fn(async () => false),
createFlow: vi.fn(async () => 'created'),
updateFlow: vi.fn(async () => 'updated'),
getFlowByPath: vi.fn(async () => {
throw new Error('getFlowByPath mock not configured')
}),
getFlowByPathWithDraft: vi.fn(async () => {
throw new Error('getFlowByPathWithDraft mock not configured')
}),
getFlowLatestVersion: vi.fn(async () => ({ id: 1 })),
listFlows: vi.fn(async () => [])
}),
ScheduleService: wrapService(actual.ScheduleService, {
existsSchedule: vi.fn(async () => false),
getSchedule: vi.fn(async () => {
throw new Error('getSchedule mock not configured')
})
}),
HttpTriggerService: wrapService(actual.HttpTriggerService, {
existsHttpTrigger: vi.fn(async () => false),
getHttpTrigger: vi.fn(async () => {
throw new Error('getHttpTrigger mock not configured')
})
}),
AppService: wrapService(actual.AppService, {
existsApp: vi.fn(async () => false),
getAppByPathWithDraft: vi.fn(async () => {
throw new Error('getAppByPathWithDraft mock not configured')
}),
listApps: vi.fn(async () => [])
}),
ResourceService: wrapService(actual.ResourceService, {
existsResource: vi.fn(async () => false),
getResource: vi.fn(async () => {
throw new Error('getResource mock not configured')
})
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: vi.fn(async () => false)
existsVariable: vi.fn(async () => false),
getVariable: vi.fn(async () => {
throw new Error('getVariable mock not configured')
}),
createVariable: vi.fn(async () => 'created'),
updateVariable: vi.fn(async () => 'updated')
})
}
})
import { globalTools, prepareGlobalUserMessage } from './core'
import { globalDraftStore } from './draftStore.svelte'
import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './core'
import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte'
import { clearGlobalDrafts } from './userDraftAdapter'
import {
AppService,
FlowService,
HttpTriggerService,
ResourceService,
ScheduleService,
ScriptService,
VariableService
} from '$lib/gen'
import type { Tool, ToolCallbacks } from '../shared'
const WORKSPACE = 'global-core-test'
@@ -84,9 +142,20 @@ async function callGlobalTool(
})
}
function localStorageSnapshot(): string {
const values: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key) values.push(`${key}: ${localStorage.getItem(key)}`)
}
return values.join('\n')
}
describe('global AI tools', () => {
beforeEach(() => {
globalDraftStore.clearDrafts(WORKSPACE)
__resetUserDraftForTesting()
localStorage.clear()
clearGlobalDrafts(WORKSPACE)
vi.clearAllMocks()
})
@@ -105,6 +174,7 @@ describe('global AI tools', () => {
const item = JSON.parse(raw)
expect(raw).not.toContain('super-secret-token')
expect(localStorageSnapshot()).not.toContain('super-secret-token')
expect(item).toEqual({
type: 'variable',
path: 'f/secrets/api_key',
@@ -113,6 +183,837 @@ describe('global AI tools', () => {
})
})
it('writes resource drafts in the editor UserDraft shape', async () => {
vi.mocked(ResourceService.existsResource).mockResolvedValueOnce(true)
vi.mocked(ResourceService.getResource).mockResolvedValueOnce({
path: 'f/resources/db',
description: 'existing database',
value: { host: 'old.example.com', port: 5432 },
resource_type: 'postgresql',
labels: ['prod'],
ws_specific: true,
edited_at: '2026-05-22T09:30:00Z'
} as any)
await callGlobalTool('write_resource', {
path: 'f/resources/db',
value: { host: 'new.example.com', port: 5432 },
resource_type: 'postgresql'
})
expect(UserDraft.get<any>('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
path: 'f/resources/db',
description: 'existing database',
args: { host: 'new.example.com', port: 5432 },
labels: ['prod'],
wsSpecific: true,
resource_type: 'postgresql'
})
expect(UserDraft.getMeta('resource', 'f/resources/db', { workspace: WORKSPACE })).toEqual({
remoteRev: '2026-05-22T09:30:00Z'
})
})
it('writes variable drafts in the editor UserDraft shape', async () => {
vi.mocked(VariableService.existsVariable).mockResolvedValueOnce(true)
vi.mocked(VariableService.getVariable).mockResolvedValueOnce({
path: 'f/secrets/api_key',
value: undefined,
is_secret: true,
description: 'old description',
account: 123,
is_oauth: true,
expires_at: '2026-06-22T09:30:00Z',
labels: ['prod'],
ws_specific: true,
edited_at: '2026-05-22T09:30:00Z'
} as any)
await callGlobalTool('write_variable', {
path: 'f/secrets/api_key',
value: 'new-secret-token',
is_secret: true,
description: 'new description'
})
expect(UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
path: 'f/secrets/api_key',
variable: {
value: '',
is_secret: true,
description: 'new description'
},
labels: ['prod'],
wsSpecific: true,
account: 123,
is_oauth: true,
expires_at: '2026-06-22T09:30:00Z'
})
expect(UserDraft.getMeta('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toEqual({
remoteRev: '2026-05-22T09:30:00Z'
})
expect(localStorageSnapshot()).not.toContain('new-secret-token')
})
it('deploys secret variable drafts with ephemeral values only', async () => {
await callGlobalTool('write_variable', {
path: 'f/secrets/api_key',
value: 'new-secret-token',
is_secret: true,
description: 'new description'
})
expect(
UserDraft.get<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/secrets/api_key',
variable: {
value: '',
is_secret: true,
description: 'new description'
},
wsSpecific: false
})
expect(localStorageSnapshot()).not.toContain('new-secret-token')
await callGlobalTool('deploy_workspace_item', {
type: 'variable',
path: 'f/secrets/api_key'
})
expect(VariableService.createVariable).toHaveBeenCalledWith({
workspace: WORKSPACE,
requestBody: expect.objectContaining({
path: 'f/secrets/api_key',
value: 'new-secret-token',
is_secret: true,
description: 'new description',
ws_specific: false
})
})
expect(UserDraft.get('variable', 'f/secrets/api_key', { workspace: WORKSPACE })).toBeUndefined()
expect(localStorageSnapshot()).not.toContain('new-secret-token')
})
it('does not deploy a secret variable draft when the ephemeral value is gone', async () => {
UserDraft.save(
'variable',
'f/secrets/api_key',
{
path: 'f/secrets/api_key',
variable: {
value: '',
is_secret: true,
description: 'new description'
},
labels: undefined,
wsSpecific: false
},
{ workspace: WORKSPACE }
)
await expect(
callGlobalTool('deploy_workspace_item', {
type: 'variable',
path: 'f/secrets/api_key'
})
).rejects.toThrow('secret draft values are kept only in memory')
expect(VariableService.createVariable).not.toHaveBeenCalled()
expect(VariableService.updateVariable).not.toHaveBeenCalled()
})
it('writes script drafts into UserDraft', async () => {
const content = 'export async function main() {\n\treturn "hello"\n}'
await callGlobalTool('write_script', {
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
content
})
expect(UserDraft.get<any>('script', 'f/scripts/hello', { workspace: WORKSPACE })).toMatchObject(
{
path: 'f/scripts/hello',
summary: 'Hello script',
language: 'bun',
content
}
)
})
it('applies path_prefix to local drafts before enforcing the result limit', async () => {
await callGlobalTool('write_script', {
path: 'f/other/outside',
summary: 'Outside draft',
language: 'bun',
content: 'export async function main() { return "outside" }'
})
await callGlobalTool('write_script', {
path: 'f/matching/inside',
summary: 'Inside draft',
language: 'bun',
content: 'export async function main() { return "inside" }'
})
const raw = await callGlobalTool('list_workspace_items', {
types: ['script'],
path_prefix: 'f/matching/',
limit: 1
})
expect(JSON.parse(raw)).toEqual([
expect.objectContaining({
type: 'script',
path: 'f/matching/inside',
isDraft: true
})
])
})
it('lists and edits the live script editor draft through its effective path', async () => {
UserDraft.save(
'script',
'',
{
path: 'u/admin/amazed_script',
summary: 'Live script',
description: '',
content: 'export async function main(a: number, b: number) {\n\treturn a + b\n}',
schema: {},
is_template: false,
language: 'bun',
kind: 'script'
},
{ workspace: WORKSPACE }
)
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'script',
storagePath: '',
effectivePath: 'u/admin/amazed_script'
})
const listRaw = await callGlobalTool('list_workspace_items', { types: ['script'] })
expect(JSON.parse(listRaw)).toContainEqual(
expect.objectContaining({
type: 'script',
path: 'u/admin/amazed_script',
isDraft: true,
isLiveDraft: true
})
)
await callGlobalTool('edit_script', {
path: 'u/admin/amazed_script',
old_string: 'return a + b',
new_string: 'return a * b'
})
expect(UserDraft.get<any>('script', '', { workspace: WORKSPACE })).toMatchObject({
path: 'u/admin/amazed_script',
content: 'export async function main(a: number, b: number) {\n\treturn a * b\n}'
})
expect(
UserDraft.get('script', 'u/admin/amazed_script', { workspace: WORKSPACE })
).toBeUndefined()
})
it('lists and writes the live flow editor draft through its effective path', async () => {
UserDraft.save(
'flow',
'',
{
path: '',
summary: 'Live flow',
value: { modules: [] },
schema: {},
edited_by: '',
edited_at: '',
archived: false,
extra_perms: {}
},
{ workspace: WORKSPACE }
)
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'flow',
storagePath: '',
effectivePath: 'u/admin/live_flow'
})
const listRaw = await callGlobalTool('list_workspace_items', { types: ['flow'] })
expect(JSON.parse(listRaw)).toContainEqual(
expect.objectContaining({
type: 'flow',
path: 'u/admin/live_flow',
isDraft: true,
isLiveDraft: true
})
)
await callGlobalTool('write_flow', {
path: 'u/admin/live_flow',
summary: 'Updated live flow',
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
})
expect(UserDraft.get<any>('flow', '', { workspace: WORKSPACE })).toMatchObject({
path: 'u/admin/live_flow',
summary: 'Updated live flow',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
})
expect(UserDraft.get('flow', 'u/admin/live_flow', { workspace: WORKSPACE })).toBeUndefined()
})
it('writes the live raw app editor draft through its effective path', async () => {
UserDraft.save(
'raw_app',
'',
{
summary: 'Live app',
files: { '/src/App.tsx': 'export default function App() { return null }' },
runnables: {},
data: { tables: [] }
},
{ workspace: WORKSPACE }
)
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'raw_app',
storagePath: '',
effectivePath: 'u/admin/live_app'
})
await callGlobalTool('write_app_file', {
path: 'u/admin/live_app',
file_path: '/src/New.tsx',
content: 'export default function New() { return null }'
})
expect(UserDraft.get<any>('raw_app', '', { workspace: WORKSPACE })).toMatchObject({
files: {
'/src/App.tsx': 'export default function App() { return null }',
'/src/New.tsx': 'export default function New() { return null }'
}
})
expect(UserDraft.get('raw_app', 'u/admin/live_app', { workspace: WORKSPACE })).toBeUndefined()
})
it('discards a local draft without deleting the workspace item', async () => {
await callGlobalTool('write_script', {
path: 'f/scripts/discard-me',
summary: 'Temporary draft',
language: 'bun',
content: 'export async function main() { return 1 }'
})
expect(UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })).toBeDefined()
const raw = await callGlobalTool('discard_local_draft', {
type: 'script',
path: 'f/scripts/discard-me'
})
expect(JSON.parse(raw)).toMatchObject({
success: true,
type: 'script',
path: 'f/scripts/discard-me'
})
expect(raw).toContain('The deployed workspace item was not changed')
expect(
UserDraft.get('script', 'f/scripts/discard-me', { workspace: WORKSPACE })
).toBeUndefined()
})
it('requires trigger_kind when discarding a trigger draft', async () => {
await expect(
callGlobalTool('discard_local_draft', {
type: 'trigger',
path: 'f/routes/missing-kind'
})
).rejects.toThrow('trigger_kind is required')
})
it('preserves existing script metadata and seeds freshness on first script write', async () => {
vi.mocked(ScriptService.existsScriptByPath).mockResolvedValueOnce(true)
vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({
path: 'f/scripts/existing',
hash: 'deployed-hash',
draft_created_at: '2026-05-22T10:00:00Z',
summary: 'deployed summary',
description: 'deployed description',
content: 'old deployed content',
language: 'bun',
kind: 'script',
draft: {
path: 'f/scripts/existing',
summary: 'db draft summary',
description: 'db draft description',
content: 'old draft content',
language: 'bun',
kind: 'script'
}
} as any)
await callGlobalTool('write_script', {
path: 'f/scripts/existing',
summary: 'new summary',
language: 'bun',
content: 'new content'
})
expect(
UserDraft.get<any>('script', 'f/scripts/existing', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/scripts/existing',
parent_hash: 'deployed-hash',
summary: 'new summary',
description: 'db draft description',
content: 'new content',
language: 'bun'
})
expect(UserDraft.getMeta('script', 'f/scripts/existing', { workspace: WORKSPACE })).toEqual({
remoteRev: 'deployed-hash',
remoteDraftRev: '2026-05-22T10:00:00Z'
})
})
it('preserves existing flow metadata and seeds freshness on first flow write', async () => {
vi.mocked(FlowService.existsFlowByPath).mockResolvedValueOnce(true)
vi.mocked(FlowService.getFlowLatestVersion).mockResolvedValueOnce({ id: 42 } as any)
vi.mocked(FlowService.getFlowByPathWithDraft).mockResolvedValueOnce({
path: 'f/flows/existing',
summary: 'deployed summary',
description: 'deployed description',
value: { modules: [] },
schema: { properties: { deployed: { type: 'boolean' } } },
edited_by: 'admin',
edited_at: '2026-05-22T09:00:00Z',
archived: false,
extra_perms: {},
draft_created_at: '2026-05-22T10:00:00Z',
draft: {
path: 'f/flows/existing',
summary: 'db draft summary',
description: 'db draft description',
value: { modules: [] },
schema: { properties: { draft: { type: 'string' } } },
edited_by: 'admin',
edited_at: '2026-05-22T09:30:00Z',
archived: false,
extra_perms: {}
}
} as any)
await callGlobalTool('write_flow', {
path: 'f/flows/existing',
summary: 'new summary',
modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }])
})
expect(UserDraft.get<any>('flow', 'f/flows/existing', { workspace: WORKSPACE })).toMatchObject({
path: 'f/flows/existing',
summary: 'new summary',
description: 'db draft description',
value: { modules: [{ id: 'step', value: { type: 'identity' } }] }
})
expect(UserDraft.getMeta('flow', 'f/flows/existing', { workspace: WORKSPACE })).toEqual({
remoteRev: 42,
remoteDraftRev: '2026-05-22T10:00:00Z'
})
})
it('preserves editor schedule fields when writing over an existing schedule', async () => {
vi.mocked(ScheduleService.existsSchedule).mockResolvedValueOnce(true)
vi.mocked(ScheduleService.getSchedule).mockResolvedValueOnce({
path: 'f/schedules/nightly',
schedule: '0 0 0 * * *',
timezone: 'UTC',
enabled: true,
script_path: 'f/scripts/old',
is_flow: false,
args: {},
extra_perms: { 'u/viewer': true },
email: 'admin@windmill.dev',
permissioned_as: 'u/admin',
edited_by: 'admin',
edited_at: '2026-05-22T09:00:00Z',
summary: 'old summary',
description: 'keep this description',
no_flow_overlap: true,
cron_version: 'v2'
} as any)
await callGlobalTool('write_schedule', {
path: 'f/schedules/nightly',
schedule: '0 15 0 * * *',
timezone: 'Europe/Paris',
script_path: 'f/flows/new',
is_flow: true,
args: { limit: 5 }
})
expect(
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
).toMatchObject({
path: 'f/schedules/nightly',
schedule: '0 15 0 * * *',
timezone: 'Europe/Paris',
script_path: 'f/flows/new',
is_flow: true,
args: { limit: 5 },
extra_perms: { 'u/viewer': true },
permissioned_as: 'u/admin',
summary: 'old summary',
description: 'keep this description',
no_flow_overlap: true
})
expect(
UserDraft.get<any>('trigger_schedule', 'f/schedules/nightly', { workspace: WORKSPACE })
).not.toMatchObject({
edited_by: expect.anything()
})
})
it('preserves editor trigger fields when writing over an existing trigger', async () => {
vi.mocked(HttpTriggerService.existsHttpTrigger).mockResolvedValueOnce(true)
vi.mocked(HttpTriggerService.getHttpTrigger).mockResolvedValueOnce({
path: 'f/routes/api',
script_path: 'f/scripts/old',
is_flow: false,
route_path: 'api/old',
http_method: 'post',
request_type: 'sync',
authentication_method: 'none',
is_static_website: false,
workspaced_route: false,
wrap_body: false,
raw_string: false,
mode: 'enabled',
extra_perms: { 'u/viewer': true },
workspace_id: WORKSPACE,
edited_by: 'admin',
edited_at: '2026-05-22T09:00:00Z',
permissioned_as: 'u/admin',
summary: 'old route',
description: 'keep route description'
} as any)
await callGlobalTool('write_trigger', {
kind: 'http',
config: {
path: 'f/routes/api',
script_path: 'f/flows/new',
is_flow: true,
route_path: 'api/new',
http_method: 'get',
authentication_method: 'windmill',
is_static_website: false
}
})
const draft = UserDraft.get<any>('trigger_http', 'f/routes/api', { workspace: WORKSPACE })
expect(draft).toMatchObject({
path: 'f/routes/api',
script_path: 'f/flows/new',
is_flow: true,
route_path: 'api/new',
http_method: 'get',
authentication_method: 'windmill',
extra_perms: { 'u/viewer': true },
permissioned_as: 'u/admin',
summary: 'old route',
description: 'keep route description'
})
expect(draft).not.toMatchObject({
workspace_id: expect.anything(),
edited_by: expect.anything()
})
})
it('seeds raw app draft metadata on first app write', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [3, 4],
draft_created_at: '2026-05-22T10:30:00Z',
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
},
policy: { execution_mode: 'publisher' },
custom_path: 'report',
draft: {
summary: 'saved app draft',
value: {
files: { '/src/App.tsx': 'draft content' },
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' }
},
policy: { execution_mode: 'anonymous' }
}
} as any)
await callGlobalTool('write_app_file', {
path: 'f/apps/report',
file_path: '/src/New.tsx',
content: 'export default function New() { return null }'
})
const draft = UserDraft.get<any>('raw_app', 'f/apps/report', { workspace: WORKSPACE })
expect(draft).toMatchObject({
summary: 'saved app draft',
files: {
'/src/App.tsx': 'draft content',
'/src/New.tsx': 'export default function New() { return null }'
},
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: ['orders'], datatable: 'db', schema: 'public' },
policy: { execution_mode: 'anonymous' },
custom_path: 'report'
})
expect(UserDraft.getMeta('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toEqual({
remoteRev: 4,
remoteDraftRev: '2026-05-22T10:30:00Z'
})
})
it('summarizes local raw app drafts in read_workspace_item', async () => {
UserDraft.save(
'raw_app',
'f/apps/local',
{
summary: 'local app',
files: { '/src/App.tsx': 'const frontendSecret = "do-not-dump"' },
runnables: {
main: {
type: 'inline',
inlineScript: {
language: 'bun',
content: 'const backendSecret = "do-not-dump"'
}
}
},
data: { tables: ['orders'] }
},
{ workspace: WORKSPACE }
)
const raw = await callGlobalTool('read_workspace_item', {
type: 'app',
path: 'f/apps/local'
})
const item = JSON.parse(raw)
expect(raw).not.toContain('frontendSecret')
expect(raw).not.toContain('backendSecret')
expect(item).toMatchObject({
type: 'app',
path: 'f/apps/local',
summary: 'local app',
isDraft: true,
value: {
frontend: [{ path: '/src/App.tsx', size: 'const frontendSecret = "do-not-dump"'.length }],
backend: [
expect.objectContaining({
key: 'main',
name: 'main',
type: 'inline',
language: 'bun',
contentSize: 'const backendSecret = "do-not-dump"'.length
})
],
data: { tables: ['orders'] }
}
})
expect(item.value.backend[0]).not.toHaveProperty('content')
})
it('summarizes backend raw app drafts from the same source as file reads', async () => {
const appWithDraft = {
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: ['deployed'] }
},
draft: {
summary: 'saved app draft',
value: {
files: {
'/src/App.tsx': 'draft content',
'/src/DraftOnly.tsx': 'draft-only content'
},
runnables: {
main: {
type: 'inline',
inlineScript: {
language: 'bun',
content: 'export async function main() { return "draft" }'
}
}
},
data: { tables: ['draft'] }
}
}
}
vi.mocked(AppService.getAppByPathWithDraft)
.mockResolvedValueOnce(appWithDraft as any)
.mockResolvedValueOnce(appWithDraft as any)
const raw = await callGlobalTool('read_workspace_item', {
type: 'app',
path: 'f/apps/report'
})
const item = JSON.parse(raw)
expect(raw).not.toContain('draft-only content')
expect(item).toMatchObject({
type: 'app',
path: 'f/apps/report',
summary: 'saved app draft',
value: {
frontend: [
{ path: '/src/App.tsx', size: 'draft content'.length },
{ path: '/src/DraftOnly.tsx', size: 'draft-only content'.length }
],
backend: [
expect.objectContaining({
key: 'main',
name: 'main',
type: 'inline',
language: 'bun',
contentSize: 'export async function main() { return "draft" }'.length
})
],
data: { tables: ['draft'] }
},
isDraft: false
})
await expect(
callGlobalTool('read_app_file', {
path: 'f/apps/report',
file_path: '/src/DraftOnly.tsx'
})
).resolves.toBe('draft-only content')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('reads raw app files without creating a local draft', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
},
draft: {
summary: 'saved app draft',
value: {
files: { '/src/App.tsx': 'draft content' },
runnables: {},
data: { tables: [] }
}
}
} as any)
await expect(
callGlobalTool('read_app_file', {
path: 'f/apps/report',
file_path: '/src/App.tsx'
})
).resolves.toBe('draft content')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('does not persist a raw app draft when patch_app_file validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
}
} as any)
await expect(
callGlobalTool('patch_app_file', {
path: 'f/apps/report',
file_path: '/src/App.tsx',
old_string: 'missing content',
new_string: 'replacement',
replace_all: false
})
).rejects.toThrow()
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('does not persist a raw app draft when delete_app_file validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {},
data: { tables: [] }
}
} as any)
await expect(
callGlobalTool('delete_app_file', {
path: 'f/apps/report',
file_path: '/src/Missing.tsx'
})
).rejects.toThrow('Frontend file "/src/Missing.tsx" not found in app "f/apps/report".')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('does not persist a raw app draft when delete_app_runnable validation fails', async () => {
vi.mocked(AppService.getAppByPathWithDraft).mockResolvedValueOnce({
path: 'f/apps/report',
summary: 'deployed app',
versions: [5],
value: {
files: { '/src/App.tsx': 'deployed content' },
runnables: {
main: {
type: 'inline',
inlineScript: { language: 'bun', content: 'export async function main() {}' }
}
},
data: { tables: [] }
}
} as any)
await expect(
callGlobalTool('delete_app_runnable', {
path: 'f/apps/report',
key: 'missing'
})
).rejects.toThrow('Backend runnable "missing" not found in app "f/apps/report".')
expect(UserDraft.get('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined()
})
it('fills an empty rawscript module through set_flow_module_code', async () => {
await callGlobalTool('write_flow', {
path: 'f/flows/empty-module',
@@ -138,7 +1039,7 @@ describe('global AI tools', () => {
module_id: 'empty_step',
code
})
).resolves.toContain('Updated AI draft flow')
).resolves.toContain('Updated local draft flow')
await expect(
callGlobalTool('read_flow_module_code', {
@@ -239,6 +1140,36 @@ describe('global AI tools', () => {
})
})
describe('prepareGlobalSystemMessage', () => {
it('keeps global chat draft instructions concise and user-facing', () => {
const message = prepareGlobalSystemMessage()
const content = message.content
expect(content).toContain('Draft tools create or update local drafts only')
expect(content).toContain(
'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft'
)
expect(content).not.toContain('AI draft')
expect(content).not.toContain('UserDraft')
expect(content).not.toContain('localStorage')
expect(content).not.toContain('frontend AI draft store')
})
it('exposes separate tools for discarding drafts and deleting workspace items', () => {
const discard = getGlobalTool('discard_local_draft')
const deleteItem = getGlobalTool('delete_workspace_item')
expect(discard.def.function.description).toBe(
'Discard a local draft only. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.'
)
expect(deleteItem.def.function.description).toBe(
'Delete a deployed workspace item. Mutates the workspace.'
)
expect(discard.requiresConfirmation).toBe(true)
expect(deleteItem.requiresConfirmation).toBe(true)
})
})
describe('prepareGlobalUserMessage', () => {
it('includes selected workspace item references without contents', () => {
const message = prepareGlobalUserMessage('Update these items', [
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Flow, NewScript, Script } from '$lib/gen/types.gen'
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
import type { WorkspaceItem } from './draftStore.svelte'
import type { WorkspaceItem } from './workspaceItems'
describe('global AI deploy request builders', () => {
it('preserves existing script metadata while replacing draft-controlled fields', () => {
@@ -1,5 +1,5 @@
import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen'
import type { FlowDraftValue, WorkspaceItem } from './draftStore.svelte'
import type { FlowDraftValue, WorkspaceItem } from './workspaceItems'
type ScriptWithDeployMetadata = Script & Partial<Pick<NewScript, 'assets' | 'cache_ignore_s3_path'>>
@@ -1,192 +0,0 @@
import type {
AzureTriggerData,
CreateResource,
CreateVariable,
FlowValue,
GcpTriggerData,
NewHttpTrigger,
NewKafkaTrigger,
NewMqttTrigger,
NewNatsTrigger,
NewPostgresTrigger,
NewSchedule,
NewSqsTrigger,
NewWebsocketTrigger,
Policy,
ScriptLang
} from '$lib/gen/types.gen'
/**
* Flow draft value. Mirrors what the backend's create/update flow API expects
* — the OpenFlow value, plus the inputs schema and (optional) groups.
*
* Schema and groups are split out from FlowValue intentionally so that
* deploy_workspace_item can preserve them through the draft → workspace
* round-trip; an earlier version dropped them on every deploy.
*/
export type FlowDraftValue = {
value: FlowValue
schema?: Record<string, any> | null
groups?: NonNullable<FlowValue['groups']> | null
}
export const TRIGGER_KINDS = [
'http',
'websocket',
'kafka',
'nats',
'postgres',
'mqtt',
'sqs',
'gcp',
'azure'
] as const
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
export type TriggerRequestBody =
| NewHttpTrigger
| NewWebsocketTrigger
| NewKafkaTrigger
| NewNatsTrigger
| NewPostgresTrigger
| NewMqttTrigger
| NewSqsTrigger
| GcpTriggerData
| AzureTriggerData
export type WorkspaceItemType =
| 'script'
| 'flow'
| 'schedule'
| 'trigger'
| 'resource'
| 'variable'
| 'app'
export type AppDraftValue = {
summary?: string
files: Record<string, string>
runnables: Record<string, any>
data?: any
policy?: Policy
custom_path?: string
}
export type WorkspaceItem = {
type: WorkspaceItemType
path: string
summary?: string
language?: ScriptLang
triggerKind?: TriggerKind
value?:
| string
| FlowDraftValue
| NewSchedule
| TriggerRequestBody
| CreateResource
| CreateVariable
| AppDraftValue
isDraft: boolean
}
export function getWorkspaceItemKey(
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string {
if (type === 'trigger') {
return `trigger:${triggerKind ?? ''}:${path}`
}
return `${type}:${path}`
}
function clone<T>(value: T): T {
return structuredClone($state.snapshot(value)) as T
}
class GlobalDraftStore {
private drafts = $state<Record<string, Record<string, WorkspaceItem>>>({})
private getWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
return this.drafts[workspace] ?? {}
}
private ensureWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
if (!this.drafts[workspace]) {
this.drafts[workspace] = {}
}
return this.drafts[workspace]
}
listDrafts(workspace: string): WorkspaceItem[] {
return Object.values(this.getWorkspaceDrafts(workspace)).map(clone)
}
getDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
const draft = this.getWorkspaceDrafts(workspace)[getWorkspaceItemKey(type, path, triggerKind)]
return draft ? clone(draft) : undefined
}
setDraft(workspace: string, item: WorkspaceItem): WorkspaceItem {
const stored: WorkspaceItem = { ...clone(item), isDraft: true }
this.ensureWorkspaceDrafts(workspace)[
getWorkspaceItemKey(item.type, item.path, item.triggerKind)
] = stored
return clone(stored)
}
deleteDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): void {
const drafts = this.drafts[workspace]
if (!drafts) return
delete drafts[getWorkspaceItemKey(type, path, triggerKind)]
if (Object.keys(drafts).length === 0) {
delete this.drafts[workspace]
}
}
clearDrafts(workspace: string): void {
delete this.drafts[workspace]
}
getScriptDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'script', path)
}
getFlowDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'flow', path)
}
getScheduleDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'schedule', path)
}
getTriggerDraft(workspace: string, kind: TriggerKind, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'trigger', path, kind)
}
getResourceDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'resource', path)
}
getVariableDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'variable', path)
}
getAppDraft(workspace: string, path: string): WorkspaceItem | undefined {
return this.getDraft(workspace, 'app', path)
}
}
export const globalDraftStore = new GlobalDraftStore()
@@ -1,56 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { globalDraftStore } from './draftStore.svelte'
const WORKSPACE_A = 'draft-store-test-a'
const WORKSPACE_B = 'draft-store-test-b'
function clearTestDrafts() {
globalDraftStore.clearDrafts(WORKSPACE_A)
globalDraftStore.clearDrafts(WORKSPACE_B)
}
describe('globalDraftStore', () => {
beforeEach(clearTestDrafts)
it('lists and reads drafts only from the requested workspace', () => {
globalDraftStore.setDraft(WORKSPACE_A, {
type: 'script',
path: 'f/shared/path',
language: 'bun',
value: 'export async function main() {}',
isDraft: true
})
expect(globalDraftStore.getDraft(WORKSPACE_A, 'script', 'f/shared/path')?.value).toBe(
'export async function main() {}'
)
expect(globalDraftStore.getDraft(WORKSPACE_B, 'script', 'f/shared/path')).toBeUndefined()
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(1)
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
})
it('deletes and clears drafts only from the requested workspace', () => {
globalDraftStore.setDraft(WORKSPACE_A, {
type: 'flow',
path: 'f/shared/path',
value: { value: { modules: [] }, schema: null, groups: null },
isDraft: true
})
globalDraftStore.setDraft(WORKSPACE_B, {
type: 'flow',
path: 'f/shared/path',
value: { value: { modules: [] }, schema: { workspace: WORKSPACE_B }, groups: null },
isDraft: true
})
globalDraftStore.deleteDraft(WORKSPACE_A, 'flow', 'f/shared/path')
expect(globalDraftStore.getDraft(WORKSPACE_A, 'flow', 'f/shared/path')).toBeUndefined()
expect(globalDraftStore.getDraft(WORKSPACE_B, 'flow', 'f/shared/path')).toBeDefined()
globalDraftStore.clearDrafts(WORKSPACE_B)
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(0)
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
})
})
@@ -0,0 +1,395 @@
import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen'
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
import {
UserDraft,
type UserDraftEntry,
type UserDraftItemKind,
type UserDraftMeta
} from '$lib/userDraft.svelte'
import {
getWorkspaceItemKey,
type AppDraftValue,
type ResourceDraftState,
type TriggerKind,
type TriggerRequestBody,
type VariableDraftState,
type WorkspaceItem,
type WorkspaceItemType
} from './workspaceItems'
const TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND = {
http: 'trigger_http',
websocket: 'trigger_websocket',
kafka: 'trigger_kafka',
nats: 'trigger_nats',
postgres: 'trigger_postgres',
mqtt: 'trigger_mqtt',
sqs: 'trigger_sqs',
gcp: 'trigger_gcp',
azure: 'trigger_azure'
} as const satisfies Record<TriggerKind, UserDraftItemKind>
const TRIGGER_KIND_BY_DRAFT_KIND = Object.fromEntries(
Object.entries(TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND).map(([triggerKind, draftKind]) => [
draftKind,
triggerKind
])
) as Partial<Record<UserDraftItemKind, TriggerKind>>
const GLOBAL_DRAFT_KINDS = [
'script',
'flow',
'raw_app',
'trigger_schedule',
'trigger_http',
'trigger_websocket',
'trigger_kafka',
'trigger_nats',
'trigger_postgres',
'trigger_mqtt',
'trigger_sqs',
'trigger_gcp',
'trigger_azure',
'resource',
'variable'
] as const satisfies UserDraftItemKind[]
const secretVariableDraftValues = new Map<string, Map<string, string>>()
function clone<T>(value: T): T {
return structuredClone(value) as T
}
function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue {
return {
summary: value.summary,
files: { ...(value.files ?? {}) },
runnables: { ...(value.runnables ?? {}) },
data: value.data ?? { ...DEFAULT_RAW_APP_DATA },
policy: value.policy === undefined ? undefined : clone(value.policy),
custom_path: value.custom_path
}
}
function getItemSummary(value: unknown): string | undefined {
return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined
}
export function setEphemeralSecretVariableDraftValue(
workspace: string,
path: string,
value: string
): void {
let workspaceValues = secretVariableDraftValues.get(workspace)
if (!workspaceValues) {
workspaceValues = new Map()
secretVariableDraftValues.set(workspace, workspaceValues)
}
workspaceValues.set(path, value)
}
export function getEphemeralSecretVariableDraftValue(
workspace: string,
path: string
): string | undefined {
return secretVariableDraftValues.get(workspace)?.get(path)
}
export function clearEphemeralSecretVariableDraftValue(workspace: string, path: string): void {
const workspaceValues = secretVariableDraftValues.get(workspace)
if (!workspaceValues) return
workspaceValues.delete(path)
if (workspaceValues.size === 0) secretVariableDraftValues.delete(workspace)
}
function clearEphemeralSecretVariableDraftValues(workspace: string): void {
secretVariableDraftValues.delete(workspace)
}
function itemKindFor(
type: WorkspaceItemType,
triggerKind?: TriggerKind
): UserDraftItemKind | undefined {
switch (type) {
case 'script':
case 'flow':
case 'resource':
case 'variable':
return type
case 'app':
return 'raw_app'
case 'schedule':
return 'trigger_schedule'
case 'trigger':
return triggerKind ? TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[triggerKind] : undefined
}
}
export function triggerKindToUserDraftKind(kind: TriggerKind): UserDraftItemKind {
return TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[kind]
}
function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceItem {
return {
type: 'script',
path,
summary: draft.summary,
language: draft.language,
value: draft.content,
isDraft: true
}
}
function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem {
return {
type: 'flow',
path,
summary: draft.summary,
value: {
value: draft.value,
schema: draft.schema ?? null,
groups: draft.value.groups ?? null
},
isDraft: true
}
}
function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceItem {
const value = normalizeAppDraftValue(draft)
return {
type: 'app',
path,
summary: value.summary,
value,
isDraft: true
}
}
function scheduleDraftToWorkspaceItem(path: string, draft: NewSchedule): WorkspaceItem {
return {
type: 'schedule',
path,
summary: draft.summary ?? undefined,
value: clone(draft),
isDraft: true
}
}
function triggerDraftToWorkspaceItem(
kind: TriggerKind,
path: string,
draft: TriggerRequestBody
): WorkspaceItem {
return {
type: 'trigger',
triggerKind: kind,
path,
summary: getItemSummary(draft),
value: clone(draft),
isDraft: true
}
}
function resourceDraftToWorkspaceItem(path: string, draft: ResourceDraftState): WorkspaceItem {
return {
type: 'resource',
path,
summary: draft.description || undefined,
value: {
path,
value: clone(draft.args),
description: draft.description,
resource_type: draft.resource_type ?? '',
labels: draft.labels,
ws_specific: draft.wsSpecific
},
isDraft: true
}
}
function variableDraftToWorkspaceItem(path: string, draft: VariableDraftState): WorkspaceItem {
return {
type: 'variable',
path,
summary: draft.variable.description || undefined,
value: {
path,
value: draft.variable.value,
is_secret: draft.variable.is_secret,
description: draft.variable.description,
account: draft.account,
is_oauth: draft.is_oauth,
expires_at: draft.expires_at,
labels: draft.labels,
ws_specific: draft.wsSpecific
},
isDraft: true
}
}
function userDraftEntryToWorkspaceItem(
entry: UserDraftEntry,
path = entry.path,
isLiveDraft = false
): WorkspaceItem | undefined {
let item: WorkspaceItem | undefined
switch (entry.itemKind) {
case 'script':
item = scriptDraftToWorkspaceItem(path, entry.value as NewScript)
break
case 'flow':
item = flowDraftToWorkspaceItem(path, entry.value as Flow)
break
case 'raw_app':
item = appDraftToWorkspaceItem(path, entry.value as AppDraftValue)
break
case 'trigger_schedule':
item = scheduleDraftToWorkspaceItem(path, entry.value as NewSchedule)
break
case 'resource':
item = resourceDraftToWorkspaceItem(path, entry.value as ResourceDraftState)
break
case 'variable':
item = variableDraftToWorkspaceItem(path, entry.value as VariableDraftState)
break
default: {
const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind]
item = triggerKind
? triggerDraftToWorkspaceItem(triggerKind, path, entry.value as TriggerRequestBody)
: undefined
}
}
return item && isLiveDraft ? { ...item, isLiveDraft: true } : item
}
function liveDisplayPath(
workspace: string,
itemKind: UserDraftItemKind,
storagePath: string
): { displayPath: string; isLiveDraft: boolean } {
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (liveDraft?.storagePath !== storagePath) {
return { displayPath: storagePath, isLiveDraft: false }
}
return {
displayPath: liveDraft.effectivePath || storagePath,
isLiveDraft: true
}
}
function resolveDraftStoragePath(
workspace: string,
itemKind: UserDraftItemKind,
path: string
): string {
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (!liveDraft) return path
if (path === liveDraft.storagePath || path === liveDraft.effectivePath)
return liveDraft.storagePath
return path
}
export function getGlobalDraftStoragePath(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string {
const itemKind = itemKindFor(type, triggerKind)
return itemKind ? resolveDraftStoragePath(workspace, itemKind, path) : path
}
function getGlobalDraftSlot(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
) {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return undefined
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const draft = UserDraft.get(itemKind, storagePath, { workspace })
if (draft === undefined) return undefined
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
const entry = {
workspace,
itemKind,
path: storagePath,
value: draft,
meta: {},
persisted: false,
live: false
}
const item = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
if (!item) return undefined
return { itemKind, storagePath, displayPath, item }
}
export function getGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): WorkspaceItem | undefined {
return getGlobalDraftSlot(workspace, type, path, triggerKind)?.item
}
export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
const drafts = new Map<string, WorkspaceItem>()
for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, entry.itemKind, entry.path)
const draft = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
if (!draft) continue
drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft)
}
return Array.from(drafts.values())
}
export function saveGlobalAppDraft(
workspace: string,
path: string,
value: AppDraftValue,
meta?: UserDraftMeta
): WorkspaceItem {
const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path)
const normalized = normalizeAppDraftValue(value)
if (meta) {
UserDraft.setDraftAndMeta('raw_app', storagePath, normalized, meta, { workspace })
} else {
UserDraft.save('raw_app', storagePath, normalized, { workspace })
}
const stored = getGlobalDraft(workspace, 'app', path)
if (!stored) throw new Error(`Could not read written app draft "${path}".`)
return stored
}
type DeleteGlobalDraftOptions = {
preserveLiveDraft?: boolean
}
export function deleteGlobalDraft(
workspace: string,
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind,
options: DeleteGlobalDraftOptions = {}
): void {
const itemKind = itemKindFor(type, triggerKind)
if (!itemKind) return
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) {
UserDraft.remove(itemKind, storagePath, { workspace })
} else {
UserDraft.clear(itemKind, storagePath, { workspace })
}
if (type === 'variable') clearEphemeralSecretVariableDraftValue(workspace, storagePath)
}
export function clearGlobalDrafts(workspace: string): void {
for (const draft of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
UserDraft.clear(draft.itemKind, draft.path, { workspace })
}
clearEphemeralSecretVariableDraftValues(workspace)
}
@@ -0,0 +1,122 @@
import type {
AzureTriggerData,
CreateResource,
CreateVariable,
FlowValue,
GcpTriggerData,
NewHttpTrigger,
NewKafkaTrigger,
NewMqttTrigger,
NewNatsTrigger,
NewPostgresTrigger,
NewSchedule,
NewSqsTrigger,
NewWebsocketTrigger,
Policy,
ScriptLang
} from '$lib/gen/types.gen'
/**
* Flow draft value. Mirrors what the backend's create/update flow API expects
* -- the OpenFlow value, plus the inputs schema and (optional) groups.
*
* Schema and groups are split out from FlowValue intentionally so that
* deploy_workspace_item can preserve them through the draft -> workspace
* round-trip; an earlier version dropped them on every deploy.
*/
export type FlowDraftValue = {
value: FlowValue
schema?: Record<string, any> | null
groups?: NonNullable<FlowValue['groups']> | null
}
export const TRIGGER_KINDS = [
'http',
'websocket',
'kafka',
'nats',
'postgres',
'mqtt',
'sqs',
'gcp',
'azure'
] as const
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
export type TriggerRequestBody =
| NewHttpTrigger
| NewWebsocketTrigger
| NewKafkaTrigger
| NewNatsTrigger
| NewPostgresTrigger
| NewMqttTrigger
| NewSqsTrigger
| GcpTriggerData
| AzureTriggerData
export type WorkspaceItemType =
| 'script'
| 'flow'
| 'schedule'
| 'trigger'
| 'resource'
| 'variable'
| 'app'
export type AppDraftValue = {
summary?: string
files: Record<string, string>
runnables: Record<string, any>
data?: any
policy?: Policy
custom_path?: string
}
export type ResourceDraftState = {
path: string
description: string
args: Record<string, any>
labels: string[] | undefined
wsSpecific: boolean
resource_type?: string
}
export type VariableDraftState = {
path: string
variable: { value: string; is_secret: boolean; description: string }
labels: string[] | undefined
wsSpecific: boolean
account?: number
is_oauth?: boolean
expires_at?: string
}
export type WorkspaceItem = {
type: WorkspaceItemType
path: string
summary?: string
language?: ScriptLang
triggerKind?: TriggerKind
value?:
| string
| FlowDraftValue
| NewSchedule
| TriggerRequestBody
| CreateResource
| CreateVariable
| AppDraftValue
isDraft: boolean
isLiveDraft?: boolean
}
export function getWorkspaceItemKey(
type: WorkspaceItemType,
path: string,
triggerKind?: TriggerKind
): string {
if (type === 'trigger') {
return `trigger:${triggerKind ?? ''}:${path}`
}
return `${type}:${path}`
}
@@ -33,6 +33,7 @@ export type FlowBuilderProps = {
stepsState: Record<string, stepState>
}
noInitial?: boolean
liveEditorDraftStoragePath?: string
onSaveInitial?: ({ path, id }: { path: string; id: string }) => void
onSaveDraft?: ({
path,
@@ -70,6 +70,7 @@
* key when the editor is rendered in a context that wants its own
* preference. */
sidebarStorageKey?: string
liveEditorDraftStoragePath?: string
}
let {
@@ -84,7 +85,8 @@
savedApp = $bindable(undefined),
diffDrawer = undefined,
defaultSidebarCollapsed = false,
sidebarStorageKey = 'raw-app-sidebar-collapsed'
sidebarStorageKey = 'raw-app-sidebar-collapsed',
liveEditorDraftStoragePath = undefined
}: Props = $props()
export const version: number | undefined = undefined
@@ -934,6 +936,7 @@
{newApp}
{newPath}
appPath={path}
{liveEditorDraftStoragePath}
{files}
{data}
{runnables}
@@ -125,6 +125,7 @@
onOpenYamlEditor?: () => void
sidebarCollapsed?: boolean
onToggleSidebar?: () => void
liveEditorDraftStoragePath?: string
}
let {
@@ -148,7 +149,8 @@
onRedo = undefined,
onOpenYamlEditor = undefined,
sidebarCollapsed = false,
onToggleSidebar = undefined
onToggleSidebar = undefined,
liveEditorDraftStoragePath = undefined
}: Props = $props()
let newEditedPath = $state(
@@ -159,6 +161,22 @@
)
)
$effect(() => {
if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return
const workspace = $workspaceStore
UserDraft.setLiveEditorDraft({
workspace,
itemKind: 'raw_app',
storagePath: liveEditorDraftStoragePath,
effectivePath: newEditedPath || appPath || savedApp?.path
})
return () =>
UserDraft.clearLiveEditorDraft('raw_app', {
workspace,
storagePath: liveEditorDraftStoragePath
})
})
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
let deployedBy: string | undefined = $state(undefined) // Author
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
+61 -6
View File
@@ -130,7 +130,26 @@ export type UserDraftEntry<V = unknown> = {
live: boolean
}
export type LiveEditorDraft = {
workspace: string
itemKind: UserDraftItemKind
storagePath: string
effectivePath?: string
}
export type LiveEditorDraftSpec = {
itemKind: UserDraftItemKind
storagePath: string
effectivePath?: string
workspace?: string
}
export type ClearLiveEditorDraftOptions = UserDraftOptions & {
storagePath?: string
}
const entries = new Map<string, DraftEntry>()
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
function resolveWorkspace(opts?: UserDraftOptions): string {
const ws = opts?.workspace ?? get(workspaceStore)
@@ -230,6 +249,10 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s
return `userdraft/w/${workspace}/${itemKind}/${path}`
}
function liveEditorDraftKey(workspace: string, itemKind: UserDraftItemKind): string {
return `${workspace}/${itemKind}`
}
function parseLocalStorageKey(
key: string,
workspace: string,
@@ -331,10 +354,13 @@ export const UserDraft = {
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Notify observers; preserve existing rev metadata. `untrack`ed
// read — see `set draft` below for why.
// Static writes are external mutations. Update live observers and
// force the storage slot to match, even if the live entry still has
// its initial-write skip armed.
const current = untrack(() => entry.state.val as StoredDraft<unknown> | undefined)
entry.state.val = wrap(value, extractMeta(current))
const meta = extractMeta(current)
entry.state.setWithoutPersist(wrap(value, meta))
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
return
}
// No live handle: preserve any persisted meta so the staleness
@@ -361,10 +387,10 @@ export const UserDraft = {
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
entry.state.val = wrap(value, meta)
// Static writes represent explicit external draft mutations. A
// freshly acquired live entry may still have the initial-write skip
// armed, so force the storage slot to match the live value.
entry.state.setWithoutPersist(wrap(value, meta))
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
return
}
@@ -401,9 +427,9 @@ export const UserDraft = {
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
return unwrap(entry.state.val as StoredDraft<V> | undefined)
return snapshotDraftValue(unwrap(entry.state.val as StoredDraft<V> | undefined))
}
return unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path)))
return snapshotDraftValue(unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path))))
},
/**
@@ -523,6 +549,34 @@ export const UserDraft = {
return Array.from(out.values())
},
setLiveEditorDraft(spec: LiveEditorDraftSpec): void {
const ws = resolveWorkspace({ workspace: spec.workspace })
liveEditorDrafts.set(liveEditorDraftKey(ws, spec.itemKind), {
workspace: ws,
itemKind: spec.itemKind,
storagePath: spec.storagePath,
effectivePath: spec.effectivePath || undefined
})
},
getLiveEditorDraft(
itemKind: UserDraftItemKind,
opts?: UserDraftOptions
): LiveEditorDraft | undefined {
const ws = resolveWorkspace(opts)
const draft = liveEditorDrafts.get(liveEditorDraftKey(ws, itemKind))
return draft ? { ...draft } : undefined
},
clearLiveEditorDraft(itemKind: UserDraftItemKind, opts?: ClearLiveEditorDraftOptions): void {
const ws = resolveWorkspace(opts)
const key = liveEditorDraftKey(ws, itemKind)
const draft = liveEditorDrafts.get(key)
if (!draft) return
if (opts?.storagePath !== undefined && draft.storagePath !== opts.storagePath) return
liveEditorDrafts.delete(key)
},
/**
* Like `remove`, but also resets any live handle's `draft` to
* `fallback` in-memory (so reactive readers see it immediately) and
@@ -802,4 +856,5 @@ export function gcUserDrafts(maxAgeMs: number = USER_DRAFT_GC_MAX_AGE_MS): void
/** Test-only: clear all in-memory entries. */
export function __resetUserDraftForTesting(): void {
entries.clear()
liveEditorDrafts.clear()
}
+109 -12
View File
@@ -18,6 +18,7 @@ vi.mock('svelte', async (importOriginal) => {
const { UserDraft, normalizeForCompare, localDraftDiffers, __resetUserDraftForTesting } =
await import('./userDraft.svelte')
const { workspaceStore } = await import('./stores')
const { deleteGlobalDraft } = await import('./components/copilot/chat/global/userDraftAdapter')
function flushDestroyCallbacks(): void {
const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length)
@@ -119,6 +120,78 @@ describe('UserDraft.save / get / remove (no observers)', () => {
})
})
describe('UserDraft live editor draft registry', () => {
it('stores the live editor storage path and effective path per workspace and kind', () => {
UserDraft.setLiveEditorDraft({
itemKind: 'script',
storagePath: '',
effectivePath: 'u/me/generated_script'
})
expect(UserDraft.getLiveEditorDraft('script')).toEqual({
workspace: 'test_ws',
itemKind: 'script',
storagePath: '',
effectivePath: 'u/me/generated_script'
})
})
it('keeps live editor registrations isolated by workspace', () => {
UserDraft.setLiveEditorDraft({
workspace: 'ws_a',
itemKind: 'flow',
storagePath: '',
effectivePath: 'u/me/a'
})
UserDraft.setLiveEditorDraft({
workspace: 'ws_b',
itemKind: 'flow',
storagePath: '',
effectivePath: 'u/me/b'
})
expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_a' })?.effectivePath).toBe(
'u/me/a'
)
expect(UserDraft.getLiveEditorDraft('flow', { workspace: 'ws_b' })?.effectivePath).toBe(
'u/me/b'
)
})
it('clears only the matching live editor storage path when provided', () => {
UserDraft.setLiveEditorDraft({
itemKind: 'raw_app',
storagePath: '',
effectivePath: 'u/me/live_app'
})
UserDraft.clearLiveEditorDraft('raw_app', { storagePath: 'u/me/other' })
expect(UserDraft.getLiveEditorDraft('raw_app')).toBeDefined()
UserDraft.clearLiveEditorDraft('raw_app', { storagePath: '' })
expect(UserDraft.getLiveEditorDraft('raw_app')).toBeUndefined()
})
it('can remove persisted global draft storage without blanking the live editor', () => {
const draft = { path: 'u/me/live_script', content: 'export async function main() {}' }
localStorage.setItem('userdraft/w/test_ws/script/', wrapped(draft))
const handle = UserDraft.use<typeof draft>('script', '')
UserDraft.setLiveEditorDraft({
itemKind: 'script',
storagePath: '',
effectivePath: 'u/me/live_script'
})
deleteGlobalDraft('test_ws', 'script', 'u/me/live_script', undefined, {
preserveLiveDraft: true
})
flushPersist()
expect(handle.draft).toEqual(draft)
expect(localStorage.getItem('userdraft/w/test_ws/script/')).toBeNull()
})
})
describe('UserDraft.use() — observer sync', () => {
it('loads the existing localStorage value on first use', () => {
localStorage.setItem('userdraft/w/test_ws/flow/u/me/loaded', wrapped('preloaded'))
@@ -138,24 +211,29 @@ describe('UserDraft.use() — observer sync', () => {
expect(a.draft).toBe(99)
})
it('save() propagates to live use() handles (in-memory)', () => {
it('save() propagates to live use() handles and persists immediately', () => {
const handle = UserDraft.use<number>('flow', 'u/me/observed')
expect(handle.draft).toBeUndefined()
// First write through a live entry is treated as the "initial value"
// (saveInitialValue=false) and is NOT persisted — observers still see it.
UserDraft.save('flow', 'u/me/observed', 7)
expect(handle.draft).toBe(7)
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/observed')).toBeNull()
expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(7))
// Subsequent writes persist.
UserDraft.save('flow', 'u/me/observed', 9)
expect(handle.draft).toBe(9)
flushPersist()
expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9))
})
it('get() returns a cloneable snapshot of live handle values', () => {
const handle = UserDraft.use<{ path: string; nested: { value: number } }>('script', '')
handle.draft = { path: 'u/me/live', nested: { value: 1 } }
const draft = UserDraft.get<{ path: string; nested: { value: number } }>('script', '')
expect(draft).toEqual({ path: 'u/me/live', nested: { value: 1 } })
expect(draft).not.toBe(handle.draft)
expect(() => structuredClone(draft)).not.toThrow()
})
it('remove() clears localStorage without touching the in-memory handle', () => {
// Seed localStorage so the live handle initialises from it.
localStorage.setItem('userdraft/w/test_ws/flow/u/me/removed', wrapped(1))
@@ -411,6 +489,28 @@ describe('UserDraft — rev metadata for staleness checks', () => {
)
})
it('UserDraft.save persists immediately when a live handle exists', () => {
const handle = UserDraft.use<string>('flow', 'u/me/live-save')
UserDraft.save('flow', 'u/me/live-save', 'external')
expect(handle.draft).toBe('external')
expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save')).toBe(wrapped('external'))
})
it('UserDraft.save preserves live rev metadata while forcing persistence', () => {
const handle = UserDraft.use<string>('flow', 'u/me/live-save-meta')
handle.setDraftAndMeta('baseline', { remoteRev: 5 })
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBeNull()
UserDraft.save('flow', 'u/me/live-save-meta', 'external')
expect(handle.draft).toBe('external')
expect(storedShape('userdraft/w/test_ws/flow/u/me/live-save-meta')).toBe(
JSON.stringify({ value: 'external', remoteRev: 5 })
)
})
it('handle.meta is empty for a draft persisted without rev (forward compat with older entries)', () => {
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/legacy',
@@ -503,8 +603,7 @@ describe('UserDraft.use() — reference counting & cleanup', () => {
UserDraft.save('flow', 'u/me/ref', 2)
expect(a.draft).toBe(2)
// Now persisted (second write after the baseline).
flushPersist()
// External save() calls persist immediately, even with a live handle.
expect(storedShape('userdraft/w/test_ws/flow/u/me/ref')).toBe(wrapped(2))
// Releasing the second handle drops the entry; subsequent save()
@@ -878,9 +977,7 @@ describe('UserDraft.list / clear / setDraftAndMeta', () => {
)
flushPersist()
expect(storedShape(key)).toBe(
wrapped({ path: 'f/rewrite-after-clear', content: 'new' })
)
expect(storedShape(key)).toBe(wrapped({ path: 'f/rewrite-after-clear', content: 'new' }))
})
it('list hides persisted drafts when a live handle has cleared the value', () => {
@@ -79,6 +79,8 @@
runnables: Record<string, Runnable>
data: RawAppData
summary: string
policy?: Policy
custom_path?: string
}>('raw_app', '')
// Restore the persisted autosave so a plain reload of /apps_raw/add
// resumes the last session. Captured once; the $effect below mirrors
@@ -114,13 +116,15 @@
let summary = $state(restoredDraft?.summary ?? '')
let files: Record<string, string> = $state(restoredDraft?.files ?? react19Template)
let policy: Policy = $state({
on_behalf_of: $userStore?.username.includes('@')
? $userStore?.username
: `u/${$userStore?.username}`,
on_behalf_of_email: $userStore?.email,
execution_mode: 'publisher'
})
let policy: Policy = $state(
restoredDraft?.policy ?? {
on_behalf_of: $userStore?.username.includes('@')
? $userStore?.username
: `u/${$userStore?.username}`,
on_behalf_of_email: $userStore?.email,
execution_mode: 'publisher'
}
)
let runnables: Record<string, Runnable> = $state(restoredDraft?.runnables ?? defaultRunnables)
/** Data configuration including tables and creation policy */
@@ -133,13 +137,14 @@
readFieldsRecursively(files)
readFieldsRecursively(runnables)
readFieldsRecursively(data)
readFieldsRecursively(policy)
void summary
untrack(() => {
if (firstMirror) {
firstMirror = false
draftHandle.setDraftAndMeta(undefined, {})
}
draftHandle.draft = { files, runnables, data, summary }
draftHandle.draft = { files, runnables, data, summary, policy }
})
})
@@ -150,11 +155,12 @@
const d = draftHandle.draft
if (d == null) return
untrack(() => {
if (localDraftDiffers(d, { files, runnables, data, summary })) {
if (localDraftDiffers(d, { files, runnables, data, summary, policy })) {
files = d.files
runnables = d.runnables
data = d.data
summary = d.summary
if (d.policy !== undefined) policy = d.policy
}
})
})
@@ -666,6 +672,7 @@
bind:data
{policy}
path={''}
liveEditorDraftStoragePath=""
bind:summary
newApp
/>
@@ -32,6 +32,8 @@
runnables: Record<string, any>
data: RawAppData
summary: string
policy?: any
custom_path?: string
}
let files: Record<string, string> | undefined = $state(undefined)
@@ -105,25 +107,45 @@
// Persist the bundle whenever any of the four pieces of state changes.
$effect(() => {
if (!files) return
readFieldsRecursively(files)
const currentFiles = files
if (!currentFiles) return
readFieldsRecursively(currentFiles)
readFieldsRecursively(runnables)
readFieldsRecursively(data)
readFieldsRecursively(policy)
void summary
draftHandle.draft = { files, runnables, data, summary }
draftHandle.draft = {
files: currentFiles,
runnables,
data,
summary,
policy,
custom_path: savedApp?.custom_path
}
})
// Reflect an external UserDraft.save into the form. Idempotent; the
// `!files` guard skips the reload window so it doesn't fight loadApp.
$effect(() => {
const d = draftHandle.draft
if (d == null || !files) return
const currentFiles = files
if (d == null || !currentFiles) return
untrack(() => {
if (localDraftDiffers(d, { files, runnables, data, summary })) {
if (
localDraftDiffers(d, {
files: currentFiles,
runnables,
data,
summary,
policy,
custom_path: savedApp?.custom_path
})
) {
files = d.files
runnables = d.runnables
data = d.data
summary = d.summary
if (d.policy !== undefined) policy = d.policy
}
})
})
@@ -192,7 +214,9 @@
(backendSource.value?.datatables
? { ...DEFAULT_DATA, tables: backendSource.value.datatables }
: { ...DEFAULT_DATA }),
summary: backendSource.summary ?? ''
summary: backendSource.summary ?? '',
policy: backendSource.policy ?? app_w_draft.policy,
custom_path: backendSource.custom_path ?? app_w_draft.custom_path
}
if (
@@ -240,7 +264,7 @@
runnables = localDraft.runnables
data = localDraft.data
summary = localDraft.summary
policy = app_w_draft.policy
policy = localDraft.policy ?? app_w_draft.policy
newPath = app_w_draft.path
files = localDraft.files
} else {
@@ -370,6 +394,7 @@
bind:summary
{newPath}
path={page.params.path ?? ''}
liveEditorDraftStoragePath={path}
{policy}
bind:savedApp
{diffDrawer}
@@ -200,6 +200,7 @@
onNavigate={(item) => goto(editPathFor(item))}
{initialPath}
{pathStoreInit}
liveEditorDraftStoragePath=""
bind:this={flowBuilder}
newFlow
{initialArgs}
@@ -367,6 +367,7 @@
{flowStore}
{flowStateStore}
initialPath={page.params.path ?? ''}
liveEditorDraftStoragePath={flowDraftPath}
newFlow={false}
{selectedId}
{initialArgs}
@@ -1,9 +1,11 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import {
globalDraftStore,
type WorkspaceItem
} from '$lib/components/copilot/chat/global/draftStore.svelte'
clearGlobalDrafts,
deleteGlobalDraft,
listGlobalDrafts
} from '$lib/components/copilot/chat/global/userDraftAdapter'
import type { WorkspaceItem } from '$lib/components/copilot/chat/global/workspaceItems'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { goto } from '$lib/navigation'
import { workspaceStore } from '$lib/stores'
@@ -11,6 +13,11 @@
import { onMount } from 'svelte'
let enabled = $state(false)
let refreshToken = $state(0)
function refreshDrafts() {
refreshToken += 1
}
onMount(() => {
// Dev-only route. Bounce to home when the global mode gate is closed.
@@ -18,9 +25,24 @@
if (!enabled) {
goto('/')
}
const onStorage = (event: StorageEvent) => {
if (event.key?.startsWith('userdraft/')) refreshDrafts()
}
window.addEventListener('storage', onStorage)
// Same-tab saves and live editor registry changes don't emit `storage`.
const interval = window.setInterval(refreshDrafts, 1000)
return () => {
window.removeEventListener('storage', onStorage)
window.clearInterval(interval)
}
})
let drafts = $derived($workspaceStore ? globalDraftStore.listDrafts($workspaceStore) : [])
let drafts = $derived.by(() => {
refreshToken
return $workspaceStore ? listGlobalDrafts($workspaceStore) : []
})
function draftKey(item: WorkspaceItem): string {
return `${item.type}:${item.triggerKind ?? '-'}:${item.path}`
@@ -28,12 +50,14 @@
function deleteDraft(item: WorkspaceItem) {
if (!$workspaceStore) return
globalDraftStore.deleteDraft($workspaceStore, item.type, item.path, item.triggerKind)
deleteGlobalDraft($workspaceStore, item.type, item.path, item.triggerKind)
refreshDrafts()
}
function clearAll() {
if (!$workspaceStore) return
globalDraftStore.clearDrafts($workspaceStore)
clearGlobalDrafts($workspaceStore)
refreshDrafts()
}
</script>
@@ -41,9 +65,9 @@
<div class="p-6 max-w-5xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Global AI drafts</h1>
<h1 class="text-2xl font-semibold">Global local drafts</h1>
<p class="text-sm text-tertiary">
Dev-only inspector for the in-memory global draft store.
Dev-only inspector for global local drafts.
</p>
</div>
<Button
@@ -112,6 +112,18 @@
defaultValue: templatePath || hubPath || urlScript ? undefined : defaultScript()
})
$effect(() => {
if (!$workspaceStore) return
const workspace = $workspaceStore
UserDraft.setLiveEditorDraft({
workspace,
itemKind: 'script',
storagePath: '',
effectivePath: scriptHandle.draft?.path
})
return () => UserDraft.clearLiveEditorDraft('script', { workspace, storagePath: '' })
})
// === BEGIN TEMP URL-HASH SYNC (remove with future PR) ===
// Legacy behavior: the URL hash both seeds the editor on load AND stays in
// sync with edits (encoded back into the hash, debounced). Asks the user
@@ -39,6 +39,18 @@
const draftPath = hash ? '' : (page.params.path ?? '')
const scriptHandle = UserDraft.use<EditableScript>('script', draftPath)
$effect(() => {
if (hash || !$workspaceStore) return
const workspace = $workspaceStore
UserDraft.setLiveEditorDraft({
workspace,
itemKind: 'script',
storagePath: draftPath,
effectivePath: scriptHandle.draft?.path ?? draftPath
})
return () => UserDraft.clearLiveEditorDraft('script', { workspace, storagePath: draftPath })
})
/** Some pages base64-JSON-encode a NewScript-like payload into the URL
* hash on `/scripts/edit/<path>#…`. Treat it as a one-shot seed that
* wins over local autosave + backend draft + deployed: apply, toast,