fix: mark global ai drafts explicitly

This commit is contained in:
centdix
2026-05-20 14:24:18 +02:00
parent d42279801e
commit fb05c310bb
9 changed files with 391 additions and 148 deletions
@@ -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<any>('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<any>('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<any>('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<any>('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<any>('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
})
])
})
@@ -83,6 +83,7 @@ import {
getGlobalDraft,
getGlobalDraftStoragePath,
listGlobalCurrentItems,
listGlobalDrafts,
triggerKindToUserDraftKind
} from './userDraftAdapter'
@@ -515,6 +516,13 @@ function itemMatches(
)
}
function itemMatchesPathPrefix(
item: Pick<WorkspaceItem, 'path'>,
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)
}
@@ -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<string, string>()
function clone<T>(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<LocalWorkspaceItemLookup, 'source' | 'draftSource'>): 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<string, WorkspaceItem>()
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<string, WorkspaceItem>()
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 })
}
}
@@ -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.
+87 -26
View File
@@ -47,10 +47,19 @@ export type UserDraftListOptions = UserDraftOptions & {
itemKinds?: UserDraftItemKind[]
}
export type UserDraftEntrySource = 'persisted' | 'live' | 'both'
export type UserDraftListEntry<V = unknown> = {
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<V> = {
export type UserDraftMeta = {
remoteRev?: string | number
remoteDraftRev?: string | number
draftSource?: 'external'
}
/**
@@ -140,6 +150,7 @@ function wrap<V>(value: V | undefined, meta?: UserDraftMeta): StoredDraft<V> | u
const out: StoredDraft<V> = { 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<unknown> | 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<T>(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<V>(
return !deepEqual(normalizeForCompare(localDraft), normalizeForCompare(currentConfig))
}
function saveDraft<V>(
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<unknown> | 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<unknown>(key)
persistDirect(key, value, { ...extractMeta(existing), ...metaPatch })
}
export const UserDraft = {
save<V>(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<unknown> | 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<unknown>(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<V>(
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<unknown> | undefined)
if (current === undefined) return
entry.state.val = wrap(current.value, meta)
entry.state.val = wrap(current.value, { ...extractMeta(current), ...meta })
}
const existing = readPersisted<unknown>(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<unknown> | 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<unknown> | 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<V = unknown>(
itemKind: UserDraftItemKind,
path: string,
@@ -698,9 +756,12 @@ function makeHandle<V>(
if (!state) return
const current = untrack(() => state.val as StoredDraft<V> | 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
})
}
}
}
+52 -2
View File
@@ -34,8 +34,8 @@ function flushPersist(): void {
// Helper: localStorage payloads are always wrapped as { value: <draft> } so
// future metadata fields can be added without breaking existing entries.
function wrapped<V>(value: V): string {
return JSON.stringify({ value })
function wrapped<V>(value: V, meta: Record<string, unknown> = {}): 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<number>('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<string>('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<string>('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' }
}
])
@@ -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(() => {
@@ -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
@@ -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
}
</script>
@@ -45,7 +51,7 @@
<div>
<h1 class="text-2xl font-semibold">Global AI drafts</h1>
<p class="text-sm text-tertiary">
Dev-only inspector for global AI drafts, including shared editor drafts.
Dev-only inspector for drafts written by global AI mode.
</p>
</div>
<Button
@@ -85,6 +91,8 @@
variant="default"
startIcon={{ icon: Trash2 }}
iconOnly
aria-label={`Delete draft ${draft.path}`}
title={`Delete draft ${draft.path}`}
onclick={() => deleteDraft(draft)}
/>
</div>