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 80a2b41e9e..ea4ceb5794 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -166,6 +166,10 @@ vi.mock('./rawAppBundlerBridge', () => ({ })) })) +vi.mock('$lib/workspaceDrafts.svelte', () => ({ + invalidateWorkspaceDrafts: vi.fn() +})) + import { globalTools, globalToolsFor, @@ -176,6 +180,7 @@ import { setOpenPreviewHandler } from './core' import { UserDraft, __resetUserDraftForTesting } from '$lib/userDraft.svelte' +import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import { clearGlobalDrafts } from './userDraftAdapter' import { bundleRawAppDraft } from './rawAppBundlerBridge' import { @@ -929,6 +934,70 @@ describe('global AI tools', () => { ).rejects.toThrow('trigger_kind is required') }) + it('discards a draft-only item that has no separate draft row', async () => { + // An item that exists ONLY as a draft_only row, with no draft-table entry + // (e.g. created elsewhere) — the row itself is the draft content. + await ScriptService.createScript({ + workspace: WORKSPACE, + requestBody: { + path: 'f/scripts/orphan', + summary: 'Orphan', + content: 'export async function main() { return 1 }', + language: 'bun', + draft_only: true + } as any + }) + expect(dbScriptDraft('f/scripts/orphan')).toBeUndefined() + + const raw = await callGlobalTool('discard_local_draft', { + type: 'script', + path: 'f/scripts/orphan' + }) + + expect(JSON.parse(raw)).toMatchObject({ success: true, type: 'script', path: 'f/scripts/orphan' }) + // draft_only with no deployed version -> the whole item is removed. + expect(ScriptService.deleteScriptByPath).toHaveBeenCalledWith({ + workspace: WORKSPACE, + path: 'f/scripts/orphan' + }) + }) + + it('deploys a chat-created script draft and clears the draft', async () => { + const content = 'export async function main() { return 1 }' + await callGlobalTool('write_script', { + path: 'f/scripts/to-deploy', + summary: 'Deploy me', + language: 'bun', + content + }) + expect(dbScriptDraft('f/scripts/to-deploy')).toBeDefined() + + vi.mocked(invalidateWorkspaceDrafts).mockClear() + const raw = await callGlobalTool('deploy_workspace_item', { + type: 'script', + path: 'f/scripts/to-deploy' + }) + + expect(JSON.parse(raw)).toMatchObject({ success: true, type: 'script', path: 'f/scripts/to-deploy' }) + // Deploy writes a real (non-draft_only) version; the backend clears the draft. + const createCalls = vi.mocked(ScriptService.createScript).mock.calls + const lastCreate = createCalls[createCalls.length - 1]?.[0] as any + expect(lastCreate.requestBody.draft_only).toBeFalsy() + expect(lastCreate.requestBody.content).toBe(content) + expect(dbScriptDraft('f/scripts/to-deploy')).toBeUndefined() + expect(invalidateWorkspaceDrafts).toHaveBeenCalledWith(WORKSPACE) + }) + + it('invalidates workspace draft counts after writing a draft', async () => { + await callGlobalTool('write_script', { + path: 'f/scripts/counted', + summary: '', + language: 'bun', + content: 'export async function main() {}' + }) + expect(invalidateWorkspaceDrafts).toHaveBeenCalledWith(WORKSPACE) + }) + it('preserves existing script metadata when writing over an existing item', async () => { vi.mocked(ScriptService.getScriptByPathWithDraft).mockResolvedValueOnce({ path: 'f/scripts/existing', diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 2383d65220..4c153cfec0 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -76,6 +76,7 @@ import { import type { ContextElement } from '../context' import { getDatatableTools } from '../datatableTools' import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte' +import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import { emptySchema } from '$lib/utils' import { inferArgs } from '$lib/infer' import { @@ -2352,8 +2353,9 @@ function liveDraftItem( } // The current chat draft for a script/flow: the live editor buffer if one is -// open, else the DB draft (`.draft`). `undefined` when no draft exists (or the -// item doesn't exist at all). +// open, else the DB draft (`.draft`), else the `draft_only` row itself (a +// never-deployed item whose row IS the draft content, even when no separate +// draft row exists). `undefined` when no draft exists (or the item doesn't). async function loadDbDraftItem( workspace: string, type: DbDraftKind, @@ -2364,14 +2366,16 @@ async function loadDbDraftItem( try { if (type === 'script') { const script = await ScriptService.getScriptByPathWithDraft({ workspace, path }) - if (!script.draft) return undefined - const item = scriptToItem(script.draft as NewScript, true) + const src = script.draft ?? (script.draft_only ? script : undefined) + if (!src) return undefined + const item = scriptToItem(src as NewScript, true) item.isDraft = true return item } const flow = await FlowService.getFlowByPathWithDraft({ workspace, path }) - if (!flow.draft) return undefined - const item = flowToItem(flow.draft as Flow, true) + const src = flow.draft ?? (flow.draft_only ? flow : undefined) + if (!src) return undefined + const item = flowToItem(src as Flow, true) item.isDraft = true return item } catch { @@ -2399,6 +2403,8 @@ async function saveScriptDbDraft( }) } await DraftService.createDraft({ workspace, requestBody: { path, typ: 'script', value: draft } }) + // Refresh mounted draft-count consumers (Drafts banner, compare page). + invalidateWorkspaceDrafts(workspace) } // Persist a flow DB draft, creating the backing `draft_only` row first when the @@ -2424,6 +2430,7 @@ async function saveFlowDbDraft( }) } await DraftService.createDraft({ workspace, requestBody: { path, typ: 'flow', value: draft } }) + invalidateWorkspaceDrafts(workspace) } // Delete a script/flow DB draft. If the item exists ONLY as a draft @@ -2459,6 +2466,7 @@ async function deleteScriptFlowDbDraft( // Reset any open live editor (and clear a stale localStorage buffer). deleteGlobalDraft(workspace, type, path) + invalidateWorkspaceDrafts(workspace) } async function writeScriptDraft( @@ -3461,6 +3469,9 @@ async function deployDraft( switch (type) { case 'script': { + // Script deploy writes a complete new version (createScript) — omitted + // fields are reset, not inherited. The AI only changes value/summary, so + // carry ALL other metadata forward from the currently-deployed version. const existing = (await ScriptService.existsScriptByPath({ workspace, path })) ? await ScriptService.getScriptByPath({ workspace, path }) : undefined @@ -3477,6 +3488,7 @@ async function deployDraft( break } case 'flow': { + // Flow update is a full replace; carry metadata forward from deployed. const flowDraft = draft.value as FlowDraftValue const existing = (await FlowService.existsFlowByPath({ workspace, path })) ? await FlowService.getFlowByPath({ workspace, path }) @@ -3626,6 +3638,9 @@ async function deployDraft( } deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true }) + // Deploying create/update-deletes the DB draft server-side; refresh mounted + // draft-count consumers so the Drafts banner/count drops immediately. + invalidateWorkspaceDrafts(workspace) // Reload the session preview if it's open on the deployed item. Map the // deploy type to the preview kind — a raw app deploys under 'app' but the