fix: stop picker listing a draft twice when drafts share a friendly path (#11214)

This commit is contained in:
Guilhem
2026-09-18 13:58:37 +02:00
committed by GitHub
parent 0e807fb1dd
commit 72507d52a0
3 changed files with 53 additions and 17 deletions
@@ -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.
@@ -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
+32 -14
View File
@@ -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<string, WorkspaceItem>()
for (const d of extras) if (!byPath.has(d.path)) byPath.set(d.path, d)
const claimed = new Set<WorkspaceItem>()
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<Record<WorkspaceItemKind, boolean>>
/** 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<Record<WorkspaceItemKind, WorkspaceItem[]>>
layout?: 'by-kind' | 'flat'
}): DrillNode<WorkspaceItem>[] {