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 a254d1bd3e..0b96cbfd9b 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -568,7 +568,7 @@ describe('global AI tools', () => { it('lists and edits the live script editor draft through its effective path', async () => { seedDbDraft( 'script', - '', + 'u/admin/draft_amazed', { path: 'u/admin/amazed_script', summary: 'Live script', @@ -584,7 +584,7 @@ describe('global AI tools', () => { UserDraft.setLiveEditorDraft({ workspace: WORKSPACE, itemKind: 'script', - storagePath: '', + storagePath: 'u/admin/draft_amazed', effectivePath: 'u/admin/amazed_script' }) @@ -604,7 +604,7 @@ describe('global AI tools', () => { new_string: 'return a * b' }) - expect(dbDraftValue('script', '')).toMatchObject({ + expect(dbDraftValue('script', 'u/admin/draft_amazed')).toMatchObject({ path: 'u/admin/amazed_script', content: 'export async function main(a: number, b: number) {\n\treturn a * b\n}' }) @@ -616,7 +616,7 @@ describe('global AI tools', () => { it('lists and writes the live flow editor draft through its effective path', async () => { seedDbDraft( 'flow', - '', + 'u/admin/draft_live_flow', { path: '', summary: 'Live flow', @@ -632,7 +632,7 @@ describe('global AI tools', () => { UserDraft.setLiveEditorDraft({ workspace: WORKSPACE, itemKind: 'flow', - storagePath: '', + storagePath: 'u/admin/draft_live_flow', effectivePath: 'u/admin/live_flow' }) @@ -652,7 +652,7 @@ describe('global AI tools', () => { modules: JSON.stringify([{ id: 'step', value: { type: 'identity' } }]) }) - expect(dbDraftValue('flow', '')).toMatchObject({ + expect(dbDraftValue('flow', 'u/admin/draft_live_flow')).toMatchObject({ path: 'u/admin/live_flow', summary: 'Updated live flow', value: { modules: [{ id: 'step', value: { type: 'identity' } }] } @@ -663,7 +663,7 @@ describe('global AI tools', () => { it('writes the live raw app editor draft through its effective path', async () => { seedDbDraft( 'raw_app', - '', + 'u/admin/draft_live_app', { summary: 'Live app', files: { '/src/App.tsx': 'export default function App() { return null }' }, @@ -675,7 +675,7 @@ describe('global AI tools', () => { UserDraft.setLiveEditorDraft({ workspace: WORKSPACE, itemKind: 'raw_app', - storagePath: '', + storagePath: 'u/admin/draft_live_app', effectivePath: 'u/admin/live_app' }) @@ -685,7 +685,7 @@ describe('global AI tools', () => { content: 'export default function New() { return null }' }) - expect(dbDraftValue('raw_app', '')).toMatchObject({ + expect(dbDraftValue('raw_app', 'u/admin/draft_live_app')).toMatchObject({ files: { '/src/App.tsx': 'export default function App() { return null }', '/src/New.tsx': 'export default function New() { return null }' @@ -1503,7 +1503,7 @@ describe('global AI tools', () => { it('test_run_flow uses the live flow editor test hook when the active editor matches the path', async () => { seedDbDraft( 'flow', - '', + 'u/admin/draft_live_flow_hook', { path: 'u/admin/live_flow', summary: 'Live flow', @@ -1519,7 +1519,7 @@ describe('global AI tools', () => { UserDraft.setLiveEditorDraft({ workspace: WORKSPACE, itemKind: 'flow', - storagePath: '', + storagePath: 'u/admin/draft_live_flow_hook', effectivePath: 'u/admin/live_flow' }) const testActiveFlow = vi.fn(async () => 'job-live-flow') @@ -1545,7 +1545,7 @@ describe('global AI tools', () => { it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => { seedDbDraft( 'flow', - '', + 'u/admin/draft_live_flow_fallback', { path: 'u/admin/live_flow_fallback', summary: 'Live flow fallback', @@ -1561,7 +1561,7 @@ describe('global AI tools', () => { UserDraft.setLiveEditorDraft({ workspace: WORKSPACE, itemKind: 'flow', - storagePath: '', + storagePath: 'u/admin/draft_live_flow_fallback', effectivePath: 'u/admin/live_flow_fallback' }) const testActiveFlow = vi.fn(async () => undefined) @@ -1952,7 +1952,7 @@ describe('prepareGlobalUserMessage', () => { UserDraft.setLiveEditorDraft({ workspace: WORKSPACE, itemKind: 'script', - storagePath: '', + storagePath: 'f/scripts/draft_live_greeting', effectivePath: 'f/scripts/live_greeting' }) diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 1863df254a..e2c38a4d9e 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -284,7 +284,14 @@ function resolveDraftStoragePath( path: string ): string { const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) - if (!liveDraft) return path + // Fall back to the caller's path when there's no live editor, or when + // the live editor hasn't committed a storage path yet. An empty + // storage path must never propagate to the DB seam: it can't be a + // `draft` row key and the draft routes (`/save_draft/{kind}/{*path}`) + // 404 on an empty wildcard tail. In practice new drafts always live at + // a real `u/{user}/draft_{uuid}` path (see `/scripts/add` et al.), so + // this is defensive — but it keeps `''` out of `DraftService` outright. + if (!liveDraft || !liveDraft.storagePath) return path if (path === liveDraft.storagePath || path === liveDraft.effectivePath) return liveDraft.storagePath return path @@ -465,10 +472,10 @@ function liveEditorDraftType(kind: (typeof LIVE_EDITOR_DRAFT_KINDS)[number]): Wo * `isLiveDraft`. * * This is in-memory state that the DB draft list can't represent: a brand-new - * unsaved draft has no DB row (it lives at an empty storage path), and an - * in-progress rename's effective path differs from where the draft is stored. - * Persisted draft listing comes from the backend (`includeDraftOnly` + - * `isDraft`); this only fills that in-memory gap. + * draft that hasn't been saved to the server yet has no DB row to list, and an + * in-progress rename's effective path differs from the `u/{user}/draft_{uuid}` + * path where the draft is stored. Persisted draft listing comes from the + * backend (`includeDraftOnly` + `isDraft`); this only fills that in-memory gap. * * Existence and path are taken straight from the live registry * (`getLiveEditorDraft`) — not gated on the in-tab value cell, which is only