diff --git a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte index 39724265a9..e321679a9b 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -5,9 +5,10 @@ the workspace-specific public API (kinds, scope = `{ kind, dir? }`, currentItem, leaf/branch icons) so callers (BreadcrumbSegment, EditorHeader) don't need to know about the generic tree model underneath. -Surfaces AI-created localStorage drafts (via `listGlobalDrafts`) as extra -items alongside the backend-loaded list, so chat-scaffolded scripts/flows/ -apps that haven't been deployed yet are still navigable. Gated on +Surfaces the session's drafts (via `listGlobalDrafts`: backend draft rows +overlaid with live editor cells) as extra items alongside the backend-loaded +list, so drafts the listing does not show yet (unsaved cells, a rename typed +in a live editor) are still navigable. Gated on `isGlobalAiEnabled()` — without sessions, the only UserDrafts present are standalone editor autosaves and surfacing those in the breadcrumb picker would be surprising. diff --git a/frontend/src/lib/components/workspaceTree.test.ts b/frontend/src/lib/components/workspaceTree.test.ts index 3e1dabd7a5..f9c00b4d7b 100644 --- a/frontend/src/lib/components/workspaceTree.test.ts +++ b/frontend/src/lib/components/workspaceTree.test.ts @@ -373,6 +373,23 @@ describe('buildWorkspaceTree', () => { expect(admin.children.map((c) => c.key)).toEqual([leafKeyFor('app', 'u/admin/draft_abc')]) }) + it('keeps one leaf per draft when two drafts share a friendly path', () => { + const a = { ...item('flow', 'u/admin/draft_a'), draftPath: 'u/admin/fond_flow' } + const b = { ...item('flow', 'u/admin/draft_b'), draftPath: 'u/admin/fond_flow' } + const tree = buildWorkspaceTree({ + loaded: { flow: [a, b] }, + kinds: ['flow'], + loadingKind: {}, + // b's extra first, so a friendly-path match would fold it into a; + // the repeat of a stands for a stale snapshot re-keyed twice. + extraItemsByKind: { flow: [{ ...b }, { ...a }, { ...a }] } + }) + const admin = findBranch(tree, dirKey('flow', 'u/admin')) + expect(admin.children.map((c) => c.key).sort()).toEqual( + [leafKeyFor('flow', 'u/admin/draft_a'), leafKeyFor('flow', 'u/admin/draft_b')].sort() + ) + }) + it('folds a mid-rename live extra into the stale loaded row: one storage-keyed leaf under the typed folder', () => { // Session picker while a rename's autosave is pending: listApps still // carries the pre-rename friendly path, the live cell extra (re-keyed to diff --git a/frontend/src/lib/components/workspaceTree.ts b/frontend/src/lib/components/workspaceTree.ts index 3a922ef45b..9475b58af5 100644 --- a/frontend/src/lib/components/workspaceTree.ts +++ b/frontend/src/lib/components/workspaceTree.ts @@ -136,13 +136,17 @@ function dirToBranch( } } -/** Merge AI-created in-memory drafts (or any caller-provided extras) into a - * kind's loaded list. The chat tools / session previews scaffold items via - * `UserDraft` before the user deploys; those should be navigable from the - * picker. An extra matching a loaded item (by storage or friendly path — else - * one draft renders as two leaves) is folded into it: the loaded row wins on - * backend metadata (summary etc.), but the extra's `draftPath` is overlaid - * when set — a live editor cell knows a rename before the backend list does. */ +/** Merge the session's drafts (or any caller-provided extras) into a kind's + * loaded list, so drafts the backend listing does not show yet (unsaved + * cells, a rename typed in a live editor) stay navigable. An extra matching + * a loaded item is folded into it: the loaded row wins on backend metadata + * (summary etc.), but the extra's `draftPath` is overlaid when set — a live + * editor cell knows a rename before the backend list does. + * + * Match on the storage path first. The friendly path is only a fallback for + * an extra still keyed by it, and each extra is claimed once: several drafts + * can share one friendly path, and an extra folded into the wrong row would + * leave its own row's extra over, rendering a second leaf with that row's key. */ function withExtras( items: WorkspaceItem[], k: WorkspaceItemKind, @@ -150,16 +154,30 @@ function withExtras( ): WorkspaceItem[] { const extras = extraItemsByKind?.[k] if (!extras || extras.length === 0) return items - const leftover = new Set(extras) - const merged = items.map((it) => { - const ex = extras.find((d) => itemMatchesPath(it, d.path) || itemMatchesPath(it, d.draftPath)) + const byPath = new Map() + for (const d of extras) if (!byPath.has(d.path)) byPath.set(d.path, d) + const claimed = new Set() + const exact = items.map((it) => { + const ex = byPath.get(it.path) + if (ex) claimed.add(ex) + return ex + }) + const merged = items.map((it, i) => { + let ex = exact[i] + if (!ex && it.draftPath !== undefined) { + const byFriendly = byPath.get(it.draftPath) + if (byFriendly && !claimed.has(byFriendly)) { + ex = byFriendly + claimed.add(ex) + } + } if (!ex) return it - leftover.delete(ex) return ex.draftPath !== undefined && ex.draftPath !== it.draftPath ? { ...it, draftPath: ex.draftPath } : it }) - return leftover.size > 0 ? merged.concat([...leftover]) : merged + const leftover = [...byPath.values()].filter((d) => !claimed.has(d)) + return leftover.length > 0 ? merged.concat(leftover) : merged } /** Build the workspace drill tree. @@ -187,8 +205,8 @@ export function buildWorkspaceTree(opts: { * loading state (e.g. chat picker, which preloads eagerly) can omit it. */ loadingKind?: Partial> /** Per-kind extras to merge into the loaded list before tree-building - * (e.g. AI-created localStorage drafts surfaced by the workspace adapter). - * Extras whose path matches an already-loaded item are dropped. */ + * (e.g. the session's drafts from `listGlobalDrafts`, keyed by storage + * path). An extra matching an already-loaded item is folded into it. */ extraItemsByKind?: Partial> layout?: 'by-kind' | 'flat' }): DrillNode[] {