From fb05c310bb6288191509486ccb2dbf08000dcdf3 Mon Sep 17 00:00:00 2001 From: centdix Date: Wed, 20 May 2026 14:24:18 +0200 Subject: [PATCH] fix: mark global ai drafts explicitly --- .../copilot/chat/global/core.test.ts | 139 +++++++++++++- .../components/copilot/chat/global/core.ts | 34 +++- .../copilot/chat/global/userDraftAdapter.ts | 181 ++++++++---------- .../triggers/useTriggerDraftSync.svelte.ts | 2 +- frontend/src/lib/userDraft.svelte.ts | 113 ++++++++--- frontend/src/lib/userDraft.test.ts | 54 +++++- .../(root)/(logged)/apps_raw/add/+page.svelte | 2 +- .../apps_raw/edit/[...path]/+page.svelte | 2 +- .../(logged)/global_drafts/+page.svelte | 12 +- 9 files changed, 391 insertions(+), 148 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index ca1849d5fd..7068abea80 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -120,6 +120,7 @@ vi.mock('$lib/gen', async () => { }) import { globalTools, prepareGlobalUserMessage } from './core' +import { deleteGlobalDraft, listGlobalDrafts } from './userDraftAdapter' import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte' import type { Tool, ToolCallbacks } from '../shared' @@ -257,6 +258,102 @@ describe('global AI tools', () => { expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/open-editor`)).toBeNull() }) + it('does not treat a clean live editor baseline as a deployable global draft', async () => { + const content = 'export async function main() {\n\treturn "baseline"\n}' + const handle = UserDraft.use('script', 'f/scripts/baseline', { workspace: WORKSPACE }) + handle.setDraftAndMeta( + { + path: 'f/scripts/baseline', + summary: 'Clean baseline', + language: 'bun', + content + }, + { remoteRev: 'v1' } + ) + + expect(listGlobalDrafts(WORKSPACE)).toEqual([]) + await expect( + callGlobalTool('deploy_workspace_item', { + type: 'script', + path: 'f/scripts/baseline' + }) + ).rejects.toThrow('No AI draft found for script "f/scripts/baseline".') + }) + + it('does not treat a persisted editor baseline as a deployable global draft', async () => { + const key = `userdraft/w/${WORKSPACE}/script/f/scripts/persisted-baseline` + localStorage.setItem( + key, + JSON.stringify({ + value: { + path: 'f/scripts/persisted-baseline', + summary: 'Persisted baseline', + language: 'bun', + content: 'export async function main() {\n\treturn "baseline"\n}', + parent_hash: 'h1' + }, + remoteRev: 'h1' + }) + ) + + expect(listGlobalDrafts(WORKSPACE)).toEqual([]) + await expect( + callGlobalTool('deploy_workspace_item', { + type: 'script', + path: 'f/scripts/persisted-baseline' + }) + ).rejects.toThrow('No AI draft found for script "f/scripts/persisted-baseline".') + }) + + it('persists AI writes into a live editor handle so they remain deployable', async () => { + const handle = UserDraft.use('script', 'f/scripts/live-ai', { workspace: WORKSPACE }) + handle.setDraftAndMeta( + { + path: 'f/scripts/live-ai', + summary: 'Clean baseline', + language: 'bun', + content: 'export async function main() {\n\treturn 1\n}' + }, + { remoteRev: 'v1' } + ) + + await callGlobalTool('write_script', { + path: 'f/scripts/live-ai', + summary: 'AI edit', + language: 'bun', + content: 'export async function main() {\n\treturn 2\n}' + }) + + expect(handle.draft?.content).toContain('return 2') + expect(listGlobalDrafts(WORKSPACE)).toEqual([ + expect.objectContaining({ + type: 'script', + path: 'f/scripts/live-ai', + summary: 'AI edit', + isDraft: true + }) + ]) + expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/live-ai`)).not.toBeNull() + }) + + it('deleteGlobalDraft clears both persisted storage and any live handle state', async () => { + const handle = UserDraft.use('script', 'f/scripts/delete-live', { workspace: WORKSPACE }) + + await callGlobalTool('write_script', { + path: 'f/scripts/delete-live', + summary: 'Delete me', + language: 'bun', + content: 'export async function main() {\n\treturn "delete"\n}' + }) + expect(handle.draft).toBeDefined() + expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/delete-live`)).not.toBeNull() + + deleteGlobalDraft(WORKSPACE, 'script', 'f/scripts/delete-live') + + expect(handle.draft).toBeUndefined() + expect(localStorage.getItem(`userdraft/w/${WORKSPACE}/script/f/scripts/delete-live`)).toBeNull() + }) + it('lists and edits a new script draft stored under the empty add-editor key by its assigned path', async () => { const content = 'export async function main() {\n\treturn 1\n}' const handle = UserDraft.use('script', '', { workspace: WORKSPACE }) @@ -348,11 +445,49 @@ describe('global AI tools', () => { type: 'script', path: 'f/scripts/listed', summary: 'Listed script', - isDraft: false + isDraft: true }) ]) }) + it('keeps local UserDraft overlays marked as drafts after path_prefix filtering', async () => { + await callGlobalTool('write_script', { + path: 'f/scripts/listed', + summary: 'Listed script', + language: 'bun', + content: 'export async function main() { return 1 }' + }) + + const raw = await callGlobalTool('list_workspace_items', { + types: ['script'], + path_prefix: 'f/scripts' + }) + + expect(JSON.parse(raw)).toEqual([ + expect.objectContaining({ + type: 'script', + path: 'f/scripts/listed', + isDraft: true + }) + ]) + }) + + it('applies path_prefix to local UserDraft overlays in list_workspace_items', async () => { + await callGlobalTool('write_script', { + path: 'f/scripts/listed', + summary: 'Listed script', + language: 'bun', + content: 'export async function main() { return 1 }' + }) + + const raw = await callGlobalTool('list_workspace_items', { + types: ['script'], + path_prefix: 'f/other' + }) + + expect(JSON.parse(raw)).toEqual([]) + }) + it('does not inspect unrelated partial flow UserDraft entries when listing scripts', async () => { UserDraft.save('flow', 'f/flows/partial', {}, { workspace: WORKSPACE }) @@ -463,7 +598,7 @@ describe('global AI tools', () => { triggerKind: 'http', path: 'f/triggers/hook', summary: 'Hook trigger', - isDraft: false + isDraft: true }) ]) }) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 8ca355a73f..0b351ca6d3 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -83,6 +83,7 @@ import { getGlobalDraft, getGlobalDraftStoragePath, listGlobalCurrentItems, + listGlobalDrafts, triggerKindToUserDraftKind } from './userDraftAdapter' @@ -515,6 +516,13 @@ function itemMatches( ) } +function itemMatchesPathPrefix( + item: Pick, + pathPrefix: string | undefined +): boolean { + return !pathPrefix || item.path.startsWith(pathPrefix) +} + function scriptToItem(script: Script, includeValue: boolean): WorkspaceItem { return { type: 'script', @@ -1279,7 +1287,19 @@ export const globalTools: Tool<{}>[] = [ ) } + for (const draftItem of listGlobalDrafts(workspace)) { + if (!types.includes(draftItem.type)) continue + byKey.set( + getWorkspaceItemKey(draftItem.type, draftItem.path, draftItem.triggerKind), + { + ...draftItem, + value: undefined + } + ) + } + const results = Array.from(byKey.values()) + .filter((item) => itemMatchesPathPrefix(item, parsed.path_prefix)) .filter((item) => itemMatches(item, parsed.query)) .slice(0, limit) @@ -1795,7 +1815,7 @@ async function writeScriptDraft( } } - UserDraft.save('script', draftStoragePath, draft, { workspace }) + UserDraft.saveExternal('script', draftStoragePath, draft, { workspace }) return finishDraftWrite( getRequiredGlobalDraft(workspace, 'script', args.path), existingDraft !== undefined || backendExists, @@ -1853,7 +1873,7 @@ async function writeFlowDraft( } } - UserDraft.save('flow', draftStoragePath, draft, { workspace }) + UserDraft.saveExternal('flow', draftStoragePath, draft, { workspace }) return finishDraftWrite( getRequiredGlobalDraft(workspace, 'flow', args.path), existingDraft !== undefined || backendExists, @@ -1871,7 +1891,7 @@ async function writeScheduleDraft(args: NewSchedule, ctx: WriteDraftCtx): Promis ? false : await ScheduleService.existsSchedule({ workspace, path: args.path }) - UserDraft.save('trigger_schedule', args.path, draft, { workspace }) + UserDraft.saveExternal('trigger_schedule', args.path, draft, { workspace }) return finishDraftWrite( getRequiredGlobalDraft(workspace, 'schedule', args.path), existingDraft !== undefined || backendExists, @@ -1894,7 +1914,7 @@ async function writeTriggerDraft( ? false : await triggerServices[kind].exists({ workspace, path: args.path }) - UserDraft.save(itemKind, args.path, draft, { workspace }) + UserDraft.saveExternal(itemKind, args.path, draft, { workspace }) return finishDraftWrite( getRequiredGlobalDraft(workspace, 'trigger', args.path, kind), existingDraft !== undefined || backendExists, @@ -1915,7 +1935,7 @@ async function writeResourceDraft( ? false : await ResourceService.existsResource({ workspace, path: args.path }) - UserDraft.save('resource', args.path, draft, { workspace }) + UserDraft.saveExternal('resource', args.path, draft, { workspace }) return finishDraftWrite( getRequiredGlobalDraft(workspace, 'resource', args.path), existingDraft !== undefined || backendExists, @@ -1936,7 +1956,7 @@ async function writeVariableDraft( ? false : await VariableService.existsVariable({ workspace, path: args.path }) - UserDraft.save('variable', args.path, draft, { workspace }) + UserDraft.saveExternal('variable', args.path, draft, { workspace }) return finishDraftWrite( getRequiredGlobalDraft(workspace, 'variable', args.path), existingDraft !== undefined || backendExists, @@ -1945,7 +1965,7 @@ async function writeVariableDraft( } function saveAppDraft(workspace: string, path: string, value: AppDraftValue): WorkspaceItem { - UserDraft.save('raw_app', path, normalizeAppDraftValue(value), { workspace }) + UserDraft.saveExternal('raw_app', path, normalizeAppDraftValue(value), { workspace }) return getRequiredGlobalDraft(workspace, 'app', path) } diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 5da94e9ea4..e40f237f3e 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -5,7 +5,12 @@ import type { NewSchedule, NewScript } from '$lib/gen/types.gen' -import { UserDraft, type UserDraftItemKind, type UserDraftListEntry } from '$lib/userDraft.svelte' +import { + UserDraft, + type UserDraftEntrySource, + type UserDraftItemKind, + type UserDraftListEntry +} from '$lib/userDraft.svelte' import { getWorkspaceItemKey, type AppDraftValue, @@ -66,7 +71,6 @@ const SHARED_DRAFT_KINDS = [ 'variable' ] as const satisfies UserDraftItemKind[] const DEFAULT_APP_DATA = { tables: [], datatable: undefined, schema: undefined } -const storagePathByVisibleItem = new Map() function clone(value: T): T { return structuredClone(value) as T @@ -150,31 +154,6 @@ function getItemSummary(value: unknown): string | undefined { return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined } -function storagePathCacheKey( - workspace: string, - type: WorkspaceItemType, - path: string, - triggerKind?: TriggerKind -): string { - return `${workspace}:${getWorkspaceItemKey(type, path, triggerKind)}` -} - -function rememberStoragePath(workspace: string, item: WorkspaceItem, storagePath: string): void { - storagePathByVisibleItem.set( - storagePathCacheKey(workspace, item.type, item.path, item.triggerKind), - storagePath - ) -} - -function forgetStoragePath( - workspace: string, - type: WorkspaceItemType, - path: string, - triggerKind?: TriggerKind -): void { - storagePathByVisibleItem.delete(storagePathCacheKey(workspace, type, path, triggerKind)) -} - function getItemPath(storagePath: string, value: unknown): string | undefined { const valuePath = (value as { path?: string | null } | undefined)?.path return valuePath?.trim() || storagePath || undefined @@ -332,23 +311,23 @@ function sharedDraftEntryToWorkspaceItem( ): WorkspaceItem | undefined { switch (entry.itemKind) { case 'script': - return scriptDraftToWorkspaceItem(entry.path, entry.value as NewScript, isDraft) + return scriptDraftToWorkspaceItem(entry.storagePath, entry.value as NewScript, isDraft) case 'flow': - return flowDraftToWorkspaceItem(entry.path, entry.value, isDraft) + return flowDraftToWorkspaceItem(entry.storagePath, entry.value, isDraft) case 'raw_app': - return appDraftToWorkspaceItem(entry.path, entry.value as AppDraftValue, isDraft) + return appDraftToWorkspaceItem(entry.storagePath, entry.value as AppDraftValue, isDraft) case 'trigger_schedule': - return scheduleDraftToWorkspaceItem(entry.path, entry.value as NewSchedule, isDraft) + return scheduleDraftToWorkspaceItem(entry.storagePath, entry.value as NewSchedule, isDraft) case 'resource': - return resourceDraftToWorkspaceItem(entry.path, entry.value as CreateResource, isDraft) + return resourceDraftToWorkspaceItem(entry.storagePath, entry.value as CreateResource, isDraft) case 'variable': - return variableDraftToWorkspaceItem(entry.path, entry.value as CreateVariable, isDraft) + return variableDraftToWorkspaceItem(entry.storagePath, entry.value as CreateVariable, isDraft) default: const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind] return triggerKind ? triggerDraftToWorkspaceItem( triggerKind, - entry.path, + entry.storagePath, entry.value as TriggerRequestBody, isDraft ) @@ -356,81 +335,73 @@ function sharedDraftEntryToWorkspaceItem( } } -type SharedDraftLookup = { +type LocalWorkspaceItemLookup = { storagePath: string - value: unknown + source: UserDraftEntrySource + draftSource?: UserDraftListEntry['draftSource'] + item: WorkspaceItem } -function findSharedDraft( - workspace: string, +function isDeployableEntry(entry: Pick): boolean { + return entry.source !== 'live' && entry.draftSource === 'external' +} + +function itemHasTarget( + item: WorkspaceItem, type: SharedWorkspaceItemType, path: string, triggerKind?: TriggerKind -): SharedDraftLookup | undefined { +): boolean { + if (item.type !== type) return false + if (item.path !== path) return false + return type !== 'trigger' || item.triggerKind === triggerKind +} + +function listSharedEntries( + workspace: string, + itemKinds: UserDraftItemKind[], + isDraft: boolean +): LocalWorkspaceItemLookup[] { + const entries: LocalWorkspaceItemLookup[] = [] + for (const entry of UserDraft.list({ workspace, itemKinds })) { + const item = sharedDraftEntryToWorkspaceItem(entry, isDraft) + if (!item) continue + entries.push({ + storagePath: entry.storagePath, + source: entry.source, + draftSource: entry.draftSource, + item + }) + } + return entries +} + +function findSharedEntry( + workspace: string, + type: SharedWorkspaceItemType, + path: string, + triggerKind: TriggerKind | undefined, + mode: 'draft' | 'current' +): LocalWorkspaceItemLookup | undefined { const itemKind = sharedDraftKind(type, triggerKind) if (!itemKind) return undefined - const cachedStoragePath = storagePathByVisibleItem.get( - storagePathCacheKey(workspace, type, path, triggerKind) - ) - if (cachedStoragePath !== undefined) { - const cached = UserDraft.get(itemKind, cachedStoragePath, { workspace }) - if (cached !== undefined) return { storagePath: cachedStoragePath, value: cached } - forgetStoragePath(workspace, type, path, triggerKind) - } - - const direct = UserDraft.get(itemKind, path, { workspace }) - if (direct !== undefined) return { storagePath: path, value: direct } - - if (path !== '') { - const addEditorDraft = UserDraft.get(itemKind, '', { workspace }) - if (addEditorDraft !== undefined && getItemPath('', addEditorDraft) === path) { - return { storagePath: '', value: addEditorDraft } - } - } - - for (const entry of UserDraft.list({ workspace, itemKinds: [itemKind] })) { - if (getItemPath(entry.path, entry.value) === path) { - return { storagePath: entry.path, value: entry.value } - } + for (const entry of listSharedEntries(workspace, [itemKind], mode === 'draft')) { + if (mode === 'draft' && !isDeployableEntry(entry)) continue + if (itemHasTarget(entry.item, type, path, triggerKind)) return entry } return undefined } -function getSharedDraft( +function getSharedItem( workspace: string, type: SharedWorkspaceItemType, path: string, triggerKind?: TriggerKind, - isDraft = true + mode: 'draft' | 'current' = 'draft' ): WorkspaceItem | undefined { - const draft = findSharedDraft(workspace, type, path, triggerKind) - if (!draft) return undefined - - switch (type) { - case 'script': - return scriptDraftToWorkspaceItem(draft.storagePath, draft.value as NewScript, isDraft) - case 'flow': - return flowDraftToWorkspaceItem(draft.storagePath, draft.value, isDraft) - case 'app': - return appDraftToWorkspaceItem(draft.storagePath, draft.value as AppDraftValue, isDraft) - case 'schedule': - return scheduleDraftToWorkspaceItem(draft.storagePath, draft.value as NewSchedule, isDraft) - case 'trigger': - return triggerKind - ? triggerDraftToWorkspaceItem( - triggerKind, - draft.storagePath, - draft.value as TriggerRequestBody, - isDraft - ) - : undefined - case 'resource': - return resourceDraftToWorkspaceItem(draft.storagePath, draft.value as CreateResource, isDraft) - case 'variable': - return variableDraftToWorkspaceItem(draft.storagePath, draft.value as CreateVariable, isDraft) - } + return findSharedEntry(workspace, type, path, triggerKind, mode)?.item } function deleteSharedDraft( @@ -441,8 +412,8 @@ function deleteSharedDraft( ): void { const itemKind = sharedDraftKind(type, triggerKind) if (!itemKind) return - const draft = findSharedDraft(workspace, type, path, triggerKind) - UserDraft.remove(itemKind, draft?.storagePath ?? path, { workspace }) + const draft = findSharedEntry(workspace, type, path, triggerKind, 'current') + UserDraft.clear(itemKind, draft?.storagePath ?? path, { workspace }) } export function getGlobalDraft( @@ -452,7 +423,7 @@ export function getGlobalDraft( triggerKind?: TriggerKind ): WorkspaceItem | undefined { if (isSharedWorkspaceItemType(type)) { - return getSharedDraft(workspace, type, path, triggerKind) + return getSharedItem(workspace, type, path, triggerKind, 'draft') } return undefined } @@ -469,7 +440,7 @@ export function getGlobalCurrentItem( triggerKind?: TriggerKind ): WorkspaceItem | undefined { if (isSharedWorkspaceItemType(type)) { - return getSharedDraft(workspace, type, path, triggerKind, false) + return getSharedItem(workspace, type, path, triggerKind, 'current') } return undefined } @@ -481,7 +452,7 @@ export function getGlobalDraftStoragePath( triggerKind?: TriggerKind ): string | undefined { if (isSharedWorkspaceItemType(type)) { - return findSharedDraft(workspace, type, path, triggerKind)?.storagePath + return findSharedEntry(workspace, type, path, triggerKind, 'current')?.storagePath } return undefined } @@ -489,10 +460,9 @@ export function getGlobalDraftStoragePath( export function listGlobalDrafts(workspace: string): WorkspaceItem[] { const drafts = new Map() - for (const entry of UserDraft.list({ workspace, itemKinds: [...SHARED_DRAFT_KINDS] })) { - const draft = sharedDraftEntryToWorkspaceItem(entry) - if (!draft) continue - rememberStoragePath(workspace, draft, entry.path) + for (const entry of listSharedEntries(workspace, [...SHARED_DRAFT_KINDS], true)) { + if (!isDeployableEntry(entry)) continue + const { item: draft } = entry drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft) } @@ -509,13 +479,11 @@ export function listGlobalCurrentItems( ): WorkspaceItem[] { const items = new Map() - for (const entry of UserDraft.list({ + for (const { item } of listSharedEntries( workspace, - itemKinds: sharedDraftKindsForTypes(types) - })) { - const item = sharedDraftEntryToWorkspaceItem(entry, false) - if (!item) continue - rememberStoragePath(workspace, item, entry.path) + sharedDraftKindsForTypes(types), + false + )) { items.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item) } @@ -535,7 +503,8 @@ export function deleteGlobalDraft( export function clearGlobalDrafts(workspace: string): void { for (const draft of UserDraft.list({ workspace, itemKinds: [...SHARED_DRAFT_KINDS] })) { - UserDraft.remove(draft.itemKind, draft.path, { workspace }) + if (!isDeployableEntry(draft)) continue + UserDraft.clear(draft.itemKind, draft.storagePath, { workspace }) } } diff --git a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts index d8e868559f..32dcd5b775 100644 --- a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts +++ b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts @@ -44,7 +44,7 @@ export interface TriggerDraftSync { /** * Shared local-autosave wiring for the trigger editors. Holding a live - * `UserDraft` handle is what makes an external `UserDraft.save('trigger_x', + * `UserDraft` handle is what makes an external `UserDraft.saveExternal('trigger_x', * …)` (another tab, a programmatic write) propagate into the open editor. * * - **apply-effect**: reflects external `handle.draft` changes into the form. diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index f4a9287048..939436701f 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -47,10 +47,19 @@ export type UserDraftListOptions = UserDraftOptions & { itemKinds?: UserDraftItemKind[] } +export type UserDraftEntrySource = 'persisted' | 'live' | 'both' + export type UserDraftListEntry = { workspace: string itemKind: UserDraftItemKind + /** + * Storage key path. For new-item editor drafts this can be empty even + * when the draft value already contains its final workspace path. + */ path: string + storagePath: string + source: UserDraftEntrySource + draftSource?: UserDraftMeta['draftSource'] value: V } @@ -79,6 +88,7 @@ export type UserDraftSpec = { export type UserDraftMeta = { remoteRev?: string | number remoteDraftRev?: string | number + draftSource?: 'external' } /** @@ -140,6 +150,7 @@ function wrap(value: V | undefined, meta?: UserDraftMeta): StoredDraft | u const out: StoredDraft = { value } if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev + if (meta?.draftSource !== undefined) out.draftSource = meta.draftSource return out } @@ -152,6 +163,7 @@ function extractMeta(stored: StoredDraft | undefined): UserDraftMeta { const meta: UserDraftMeta = {} if (stored.remoteRev !== undefined) meta.remoteRev = stored.remoteRev if (stored.remoteDraftRev !== undefined) meta.remoteDraftRev = stored.remoteDraftRev + if (stored.draftSource !== undefined) meta.draftSource = stored.draftSource return meta } @@ -224,7 +236,15 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s } function clone(value: T): T { - return structuredClone($state.snapshot(value)) as T + const snapshot = $state.snapshot(value) + try { + return structuredClone(snapshot) as T + } catch { + // Live editor values may contain runtime-only fields (functions, + // class instances). Listing should still expose the usable draft + // shape; downstream converters copy only the fields they understand. + return snapshot as T + } } function listLocalStorageDrafts( @@ -255,6 +275,9 @@ function listLocalStorageDrafts( workspace, itemKind, path, + storagePath: path, + source: 'persisted', + draftSource: stored?.draftSource, value: clone(value) }) } @@ -327,29 +350,53 @@ export function localDraftDiffers( return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig)) } +function saveDraft( + itemKind: UserDraftItemKind, + path: string, + value: V, + opts: UserDraftOptions | undefined, + metaPatch: UserDraftMeta, + persistLiveEntry: boolean +): void { + const ws = resolveWorkspace(opts) + const key = localStorageKey(ws, itemKind, path) + 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. + const current = untrack(() => entry.state.val as StoredDraft | undefined) + const meta: UserDraftMeta = { ...extractMeta(current), ...metaPatch } + entry.state.val = wrap(value, meta) + if (persistLiveEntry) { + persistDirect(key, value, meta) + } + return + } + + // No live handle: preserve any persisted meta so the staleness + // signal survives a write while the editor is closed. + const existing = readPersisted(key) + persistDirect(key, value, { ...extractMeta(existing), ...metaPatch }) +} + export const UserDraft = { save(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void { - const ws = resolveWorkspace(opts) - 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. - const current = untrack(() => entry.state.val as StoredDraft | undefined) - entry.state.val = wrap(value, extractMeta(current)) - return - } - // No live handle: preserve any persisted meta so the staleness - // signal survives a write while the editor is closed. - const existing = readPersisted(localStorageKey(ws, itemKind, path)) - try { - localStorage.setItem( - localStorageKey(ws, itemKind, path), - JSON.stringify(stamp(wrap(value, extractMeta(existing)))) - ) - } catch (e) { - console.error('UserDraft.save: localStorage write failed', e) - } + saveDraft(itemKind, path, value, opts, {}, false) + }, + + /** + * Persist a draft written by an external actor such as global AI mode. + * Unlike editor baseline hydration, this must be visible immediately to + * draft deployment/listing tools even when a live editor handle is open. + */ + saveExternal( + itemKind: UserDraftItemKind, + path: string, + value: V, + opts?: UserDraftOptions + ): void { + saveDraft(itemKind, path, value, opts, { draftSource: 'external' }, true) }, /** @@ -407,11 +454,14 @@ export const UserDraft = { if (entry) { const current = untrack(() => entry.state.val as StoredDraft | undefined) if (current === undefined) return - entry.state.val = wrap(current.value, meta) + entry.state.val = wrap(current.value, { ...extractMeta(current), ...meta }) } const existing = readPersisted(localStorageKey(ws, itemKind, path)) if (existing === undefined) return - persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta) + persistDirect(localStorageKey(ws, itemKind, path), existing.value, { + ...extractMeta(existing), + ...meta + }) }, /** @@ -452,10 +502,14 @@ export const UserDraft = { const value = unwrap(entry.state.val as StoredDraft | undefined) if (value === undefined) continue + const persisted = drafts.get(key) drafts.set(key, { workspace: ws, itemKind: entry.itemKind, path: entry.path, + storagePath: entry.path, + source: persisted ? 'both' : 'live', + draftSource: extractMeta(entry.state.val as StoredDraft | undefined).draftSource, value: clone(value) }) } @@ -500,6 +554,10 @@ export const UserDraft = { } }, + clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void { + UserDraft.discard(itemKind, path, undefined, opts) + }, + use( itemKind: UserDraftItemKind, path: string, @@ -698,9 +756,12 @@ function makeHandle( if (!state) return const current = untrack(() => state.val as StoredDraft | undefined) if (current === undefined) return - state.val = wrap(current.value, meta) + state.val = wrap(current.value, { ...extractMeta(current), ...meta }) if (opts?.force) { - persistDirect(localStorageKey(workspace, itemKind, path), current.value, meta) + persistDirect(localStorageKey(workspace, itemKind, path), current.value, { + ...extractMeta(current), + ...meta + }) } } } diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts index f3dc51d1b0..658930b2d4 100644 --- a/frontend/src/lib/userDraft.test.ts +++ b/frontend/src/lib/userDraft.test.ts @@ -34,8 +34,8 @@ function flushPersist(): void { // Helper: localStorage payloads are always wrapped as { value: } so // future metadata fields can be added without breaking existing entries. -function wrapped(value: V): string { - return JSON.stringify({ value }) +function wrapped(value: V, meta: Record = {}): string { + return JSON.stringify({ value, ...meta }) } // Helper: read a localStorage entry, strip the GC `lastWrittenAt` stamp so @@ -156,6 +156,19 @@ describe('UserDraft.use() — observer sync', () => { expect(storedShape('userdraft/w/test_ws/flow/u/me/observed')).toBe(wrapped(9)) }) + it('saveExternal() persists through live use() handles and marks the draft source', () => { + const handle = UserDraft.use('flow', 'u/me/external') + expect(handle.draft).toBeUndefined() + + UserDraft.saveExternal('flow', 'u/me/external', 7) + expect(handle.draft).toBe(7) + flushPersist() + + expect(storedShape('userdraft/w/test_ws/flow/u/me/external')).toBe( + wrapped(7, { draftSource: 'external' }) + ) + }) + 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)) @@ -200,6 +213,18 @@ describe('UserDraft.use() — observer sync', () => { expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/wipe')).toBeNull() }) + it('clear() clears both localStorage and the live handle', () => { + localStorage.setItem('userdraft/w/test_ws/flow/u/me/clear-live', wrapped('local-edit')) + const handle = UserDraft.use('flow', 'u/me/clear-live') + expect(handle.draft).toBe('local-edit') + + UserDraft.clear('flow', 'u/me/clear-live') + flushPersist() + + expect(handle.draft).toBeUndefined() + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear-live')).toBeNull() + }) + it('the second write through the handle setter persists to localStorage', () => { const handle = UserDraft.use('flow', 'u/me/setter') @@ -298,17 +323,39 @@ describe('UserDraft.list()', () => { workspace: 'test_ws', itemKind: 'script', path: 'u/me/script1', + storagePath: 'u/me/script1', + source: 'persisted', + draftSource: undefined, value: { content: 'code' } }, { workspace: 'test_ws', itemKind: 'flow', path: 'u/me/flow1', + storagePath: 'u/me/flow1', + source: 'persisted', + draftSource: undefined, value: { modules: [] } } ]) }) + it('exposes draftSource for externally saved drafts', () => { + UserDraft.saveExternal('script', 'u/me/script1', { content: 'code' }) + + expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([ + { + workspace: 'test_ws', + itemKind: 'script', + path: 'u/me/script1', + storagePath: 'u/me/script1', + source: 'persisted', + draftSource: 'external', + value: { content: 'code' } + } + ]) + }) + it('prefers live handle state over a stale localStorage entry', () => { const handle = UserDraft.use<{ source: string }>('script', 'u/me/live') handle.draft = { source: 'live' } @@ -319,6 +366,9 @@ describe('UserDraft.list()', () => { workspace: 'test_ws', itemKind: 'script', path: 'u/me/live', + storagePath: 'u/me/live', + source: 'both', + draftSource: undefined, value: { source: 'live' } } ]) diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte index fb68f09f1b..e9ddf0280b 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte @@ -143,7 +143,7 @@ }) }) - // Reflect an external UserDraft.save into the form. Idempotent + the + // Reflect an external UserDraft.saveExternal into the form. Idempotent + the // d == null guard keeps it from looping with the mirror above or // clobbering "start fresh" loads (which discard the in-memory draft). $effect(() => { diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 93417aa3f4..ff3c9634cb 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -113,7 +113,7 @@ draftHandle.draft = { files, runnables, data, summary } }) - // Reflect an external UserDraft.save into the form. Idempotent; the + // Reflect an external UserDraft.saveExternal into the form. Idempotent; the // `!files` guard skips the reload window so it doesn't fight loadApp. $effect(() => { const d = draftHandle.draft diff --git a/frontend/src/routes/(root)/(logged)/global_drafts/+page.svelte b/frontend/src/routes/(root)/(logged)/global_drafts/+page.svelte index cf60c21af6..b991ea5b29 100644 --- a/frontend/src/routes/(root)/(logged)/global_drafts/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/global_drafts/+page.svelte @@ -22,7 +22,11 @@ } }) - let drafts = $derived($workspaceStore ? listGlobalDrafts($workspaceStore) : []) + let refreshRevision = $state(0) + let drafts = $derived.by(() => { + refreshRevision + return $workspaceStore ? listGlobalDrafts($workspaceStore) : [] + }) function draftKey(item: WorkspaceItem): string { return `${item.type}:${item.triggerKind ?? '-'}:${item.path}` @@ -31,11 +35,13 @@ function deleteDraft(item: WorkspaceItem) { if (!$workspaceStore) return deleteGlobalDraft($workspaceStore, item.type, item.path, item.triggerKind) + refreshRevision += 1 } function clearAll() { if (!$workspaceStore) return clearGlobalDrafts($workspaceStore) + refreshRevision += 1 } @@ -45,7 +51,7 @@

Global AI drafts

- Dev-only inspector for global AI drafts, including shared editor drafts. + Dev-only inspector for drafts written by global AI mode.