From 0fd223d1bd4e9322c11ad57c6bbe4dccd92fbf5a Mon Sep 17 00:00:00 2001 From: rsk-731 Date: Mon, 20 Jul 2026 11:21:14 +0000 Subject: [PATCH] fix(desktop): stop dual main lanes for non-git project workspaces Live overlay always placed sessions under `::branch::main`, while the backend non-git heuristic keys the lane by folder path (label = basename). Overlay missed that lane by id/label and forked a phantom `main` group with the same sessions. Match existing path-keyed isMain lanes before creating a branch-style main lane. Covers the codex-research-guardian-style drill-in duplicate. --- .../sidebar/projects/workspace-groups.test.ts | 71 +++++++++++++++++++ .../chat/sidebar/projects/workspace-groups.ts | 15 ++++ 2 files changed, 86 insertions(+) diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts index b2a893af58..6548d4a3b6 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.test.ts @@ -697,6 +697,77 @@ describe('overlayLiveLanes', () => { expect(overlaid.repos[0].groups.flatMap(g => g.sessions.map(s => s.id))).toEqual(['dup']) }) + it('does not fork a phantom main lane for a non-git backend workspace lane', () => { + // Backend non-git heuristic (`project_tree._place_by_heuristic`): lane id = + // folder path, label = basename, isMain=true. Live overlay used to always + // place under `::branch::main` / label "main", miss that lane by id+label, + // and CREATE a second main lane with the same sessions — dual lanes in the + // project drill-in (e.g. main + codex-research-guardian). + const root = '/home/hermes/hermes-workspace/codex-research-guardian' + const a = makeSession(root, { id: 's1' }) // empty git_branch / git_repo_root + const b = makeSession(root, { id: 's2' }) + + const project = projectNode({ + id: root, + isAuto: true, + path: root, + repos: [ + { + id: root, + label: 'codex-research-guardian', + path: root, + sessionCount: 2, + groups: [ + lane({ + id: root, + label: 'codex-research-guardian', + isMain: true, + path: root, + sessions: [a, b] + }) + ] + } + ] + }) + + const overlaid = overlayLiveLanes(project, [a, b]) + const groups = overlaid.repos[0].groups + + expect(groups).toHaveLength(1) + expect(groups[0].id).toBe(root) + expect(groups[0].label).toBe('codex-research-guardian') + expect(groups[0].sessions.map(s => s.id).sort()).toEqual(['s1', 's2']) + expect(groups.some(g => g.label === 'main' || g.id.endsWith('::branch::main'))).toBe(false) + }) + + it('joins a fresh live session into an existing non-git workspace lane (no branch id)', () => { + const root = '/work/notes' + const existing = makeSession(root, { id: 'old' }) + + const project = projectNode({ + id: root, + isAuto: true, + path: root, + repos: [ + { + id: root, + label: 'notes', + path: root, + sessionCount: 1, + groups: [lane({ id: root, label: 'notes', isMain: true, path: root, sessions: [existing] })] + } + ] + }) + + const fresh = makeSession(root, { id: 'fresh' }) + const overlaid = overlayLiveLanes(project, [existing, fresh]) + const groups = overlaid.repos[0].groups + + expect(groups).toHaveLength(1) + expect(groups[0].id).toBe(root) + expect(groups[0].sessions.map(s => s.id).sort()).toEqual(['fresh', 'old']) + }) + it('adds a new session to an existing worktree lane keyed by a divergent id (matches by path)', () => { // Backend keyed the worktree lane off a branch-style id (no live git probe), // but the lane PATH is the worktree dir. A new session under that worktree diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts index 353e9d4ad7..756600d495 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -563,6 +563,21 @@ export function overlayRepoLanes( (placed.isMain ? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase()) : undefined) ?? + // Non-git backend heuristic (`project_tree._place_by_heuristic`): one + // isMain lane keyed by the folder path itself (id === path, label = + // basename) — not `::branch::`. Live placement always emits + // `::branch::main` / label "main", so id+label miss and used to FORK a + // phantom second main lane with the same sessions. Prefer the existing + // path-keyed main lane when present. + (placed.isMain && placedKey + ? lanes.find( + g => + g.isMain && + pathKey(g.path) === placedKey && + !g.id.includes('::branch::') && + !g.id.includes('::kanban') + ) + : undefined) ?? (!placed.isMain && placedKey ? lanes.find(g => pathKey(g.path) === placedKey) : undefined) if (!lane) {