From 3defb25a428d2563d2a67b2b65d8ed9c9cd1ce71 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Tue, 30 Jun 2026 23:40:42 +0800 Subject: [PATCH] fix(desktop): evict stale lane entry when overlay moves session to worktree When a session's cwd moves from the main checkout to a newly created worktree, overlayRepoLanes places it into the matching worktree lane but never removed the stale entry from the main lane. The session appeared under both groups until the user left and re-entered the project view. Add a cross-lane eviction loop that removes the session from all other lanes before inserting it into the target lane. --- .../sidebar/projects/workspace-groups.test.ts | 33 +++++++++++++++++++ .../chat/sidebar/projects/workspace-groups.ts | 16 +++++++++ 2 files changed, 49 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 6548d4a3b6..931a4dad5c 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 @@ -901,6 +901,39 @@ describe('overlayLiveLanes', () => { expect(overlayLiveLanes(home, [makeSession('/www/app', { id: 'fresh' })])).toBe(home) }) + + it('evicts a session from the main lane when the live overlay places it into a worktree lane', () => { + // Session was in main when the backend tree was captured, but the live + // $sessions cache now has it under a worktree cwd. The overlay must place + // it ONLY in the worktree lane — not both. + const session = makeSession('/www/app/.worktrees/feature', { id: 'moved', git_branch: 'feature' }) + + const project = projectNode({ + id: '/www/app', + repos: [ + { + id: '/www/app', + label: 'app', + path: '/www/app', + sessionCount: 1, + groups: [ + lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [session] }), + lane({ id: '/www/app/.worktrees/feature', label: 'feature', path: '/www/app/.worktrees/feature', sessions: [] }) + ] + } + ] + }) + + const overlaid = overlayLiveLanes(project, [session]) + const mainLane = overlaid.repos[0].groups.find(g => g.isMain) + const featureLane = overlaid.repos[0].groups.find(g => g.path === '/www/app/.worktrees/feature') + + // Session must NOT appear in the main lane + expect(mainLane?.sessions ?? []).toHaveLength(0) + // Session must appear only in the worktree lane + expect(featureLane?.sessions.map(s => s.id)).toEqual(['moved']) + expect(overlaid.sessionCount).toBe(1) + }) }) describe('overlayLivePreviews', () => { 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 756600d495..52370653ba 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -586,6 +586,22 @@ export function overlayRepoLanes( } } + // Evict the session from any OTHER lane the backend snapshot may have + // placed it in (e.g. a turn that moved the session's cwd from main to a + // new worktree — the overlay places it into the worktree lane, but without + // this eviction the stale main-lane entry persists and the session appears + // under both groups until the next backend tree refresh). + for (const g of lanes) { + if (g !== lane) { + const idx = g.sessions.findIndex(s => s.id === session.id) + + if (idx >= 0) { + g.sessions = [...g.sessions.slice(0, idx), ...g.sessions.slice(idx + 1)] + changed = true + } + } + } + lane.sessions = upsertSession(lane.sessions, session) changed = true }