fix: list app drafts and drop stale local drafts in ai chat

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
centdix
2026-06-03 21:56:46 +02:00
co-authored by Claude Opus 4.8
parent 855bfbf2b2
commit 780ee8c09f
3 changed files with 113 additions and 36 deletions
+4
View File
@@ -26868,6 +26868,10 @@ components:
items:
type: string
default: []
has_draft:
type: boolean
draft_only:
type: boolean
required:
- id
- workspace_id
@@ -211,7 +211,8 @@ vi.mock('$lib/gen', async () => {
existsResource: vi.fn(async () => false),
getResource: vi.fn(async () => {
throw new Error('getResource mock not configured')
})
}),
listResource: vi.fn(async () => [])
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: vi.fn(async () => false),
@@ -788,49 +789,32 @@ describe('global AI tools', () => {
})
})
it('applies path_prefix to local (live-editor) drafts before enforcing the result limit', async () => {
// list_workspace_items still surfaces live-editor localStorage drafts, so
// drive it through live editors rather than DB-only writes.
it('applies path_prefix to local drafts before enforcing the result limit', async () => {
// Group-B (resource/variable/…) drafts are localStorage-only and always
// listed; use them to exercise path_prefix filtering happening BEFORE the
// result limit (so a matching draft isn't dropped for a non-matching one).
UserDraft.save(
'script',
'resource',
'f/other/outside',
{
path: 'f/other/outside',
summary: 'Outside draft',
description: '',
content: 'export async function main() { return "outside" }',
schema: {},
is_template: false,
language: 'bun',
kind: 'script'
},
{ path: 'f/other/outside', description: '', args: {}, labels: [], wsSpecific: false },
{ workspace: WORKSPACE }
)
UserDraft.save(
'script',
'resource',
'f/matching/inside',
{
path: 'f/matching/inside',
summary: 'Inside draft',
description: '',
content: 'export async function main() { return "inside" }',
schema: {},
is_template: false,
language: 'bun',
kind: 'script'
},
{ path: 'f/matching/inside', description: '', args: {}, labels: [], wsSpecific: false },
{ workspace: WORKSPACE }
)
const raw = await callGlobalTool('list_workspace_items', {
types: ['script'],
types: ['resource'],
path_prefix: 'f/matching/',
limit: 1
})
expect(JSON.parse(raw)).toEqual([
expect.objectContaining({
type: 'script',
type: 'resource',
path: 'f/matching/inside',
isDraft: true
})
@@ -943,6 +927,76 @@ describe('global AI tools', () => {
expect(raw).not.toContain('Old deployed summary')
})
it('lists a draft-only app (includeDraftOnly) and flags it as a draft', async () => {
// `init_app` persists a `draft_only` app row; the backend excludes those
// unless `includeDraftOnly` is passed, so the app list must request it or a
// just-created app draft is invisible (scripts/flows already pass it).
vi.mocked(AppService.listApps).mockResolvedValueOnce([
{ path: 'f/apps/draft-only', summary: 'App draft', draft_only: true },
{ path: 'f/apps/deployed', summary: 'Deployed app', draft_only: false }
] as any)
const raw = await callGlobalTool('list_workspace_items', { types: ['app'] })
const items = JSON.parse(raw) as Array<{ path: string; isDraft: boolean }>
expect(items.find((i) => i.path === 'f/apps/draft-only')).toMatchObject({ isDraft: true })
expect(items.find((i) => i.path === 'f/apps/deployed')).toMatchObject({ isDraft: false })
expect(vi.mocked(AppService.listApps).mock.calls[0]?.[0]).toMatchObject({
includeDraftOnly: true
})
})
it('does not list a stale (non-live) Group-A localStorage draft', async () => {
// No live editor is registered, so `loadDraft` (read/deploy) ignores this
// localStorage entry — the list must not advertise a draft they won't use.
UserDraft.save(
'script',
'f/list/stale-local',
{
path: 'f/list/stale-local',
summary: 'Stale local',
description: '',
content: '',
schema: {},
is_template: false,
language: 'bun',
kind: 'script'
},
{ workspace: WORKSPACE }
)
const raw = await callGlobalTool('list_workspace_items', { types: ['script'] })
const items = JSON.parse(raw) as Array<{ path: string }>
expect(items.find((i) => i.path === 'f/list/stale-local')).toBeUndefined()
})
it('lists a live-editor Group-A localStorage draft', async () => {
UserDraft.setLiveEditorDraft({
workspace: WORKSPACE,
itemKind: 'script',
storagePath: 'f/list/live-only'
})
UserDraft.save(
'script',
'f/list/live-only',
{
path: 'f/list/live-only',
summary: 'Live only',
description: '',
content: '',
schema: {},
is_template: false,
language: 'bun',
kind: 'script'
},
{ workspace: WORKSPACE }
)
const raw = await callGlobalTool('list_workspace_items', { types: ['script'] })
const items = JSON.parse(raw) as Array<{ path: string; isDraft: boolean }>
expect(items.find((i) => i.path === 'f/list/live-only')).toMatchObject({ isDraft: true })
})
it('lists and edits the live script editor draft through its effective path', async () => {
UserDraft.save(
'script',
@@ -920,7 +920,10 @@ function appToItem(app: ListableApp | AppWithLastVersion, includeValue: boolean)
path: app.path,
summary: app.summary,
value: includeValue ? ((app as AppWithLastVersion).value as AppDraftValue) : undefined,
isDraft: false
// A backend `draft_only` app exists solely as a workspace draft (never
// deployed), so it is a draft. A deployed app (`draft_only` false) is not.
// Only `ListableApp` carries the flag; `AppWithLastVersion` never does.
isDraft: ('draft_only' in app ? app.draft_only : undefined) ?? false
}
}
@@ -1227,7 +1230,7 @@ async function readScriptOrFlowItem(
* (`value: undefined`) and `isDraft: true`.
*/
async function hydrateListedDbDraft(
type: 'script' | 'flow',
type: 'script' | 'flow' | 'app',
item: WorkspaceItem,
workspace: string
): Promise<WorkspaceItem> {
@@ -1325,16 +1328,23 @@ async function listWorkspaceItems(
}
if (types.includes('app')) {
// `includeDraftOnly` so a never-deployed app that exists only as a workspace
// draft (created via `init_app`) is listed — without it the backend filters
// out `draft_only` rows and the AI can't see an app draft it just created.
const apps = await AppService.listApps({
workspace,
pathStart: pathPrefix,
perPage
perPage,
includeDraftOnly: true
})
// The generated `ListableApp` exposes no `has_draft`/`draft_only` flags, so
// we can't bound an app-draft hydration the way scripts/flows do. Accept a
// possibly-stale app summary here for now; client-side regeneration of those
// flags (or a per-app draft read) is the follow-up.
for (const app of apps) items.push(appToItem(app, false))
for (const app of apps) {
const item = appToItem(app, false)
items.push(
app.has_draft || app.draft_only
? await hydrateListedDbDraft('app', item, workspace)
: item
)
}
}
return items
@@ -1557,6 +1567,15 @@ export const globalTools: Tool<{}>[] = [
for (const draft of listGlobalDrafts(workspace)) {
if (!types.includes(draft.type)) continue
if (parsed.path_prefix && !draft.path.startsWith(parsed.path_prefix)) continue
// Group-A (script/flow/app) DB drafts already come from the backend list
// (`includeDraftOnly`). For those, localStorage is authoritative ONLY
// while a live editor is open — `read`/`deploy` ignore a stale closed-
// editor (or pre-upgrade) local draft via `loadDraft`, so the list must
// not advertise one either. Group B has no DB draft, so its localStorage
// entry is the source of truth and always counts.
const isGroupA =
draft.type === 'script' || draft.type === 'flow' || draft.type === 'app'
if (isGroupA && !draft.isLiveDraft) continue
byKey.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), {
...draft,
value: undefined