From aed682629ff258bf129c7b44d5d0c8a205d4b809 Mon Sep 17 00:00:00 2001 From: KoNit-K Date: Fri, 18 Sep 2026 12:07:46 +0800 Subject: [PATCH] fix(desktop): retain backend worktree project ownership --- apps/desktop/src/app/chat/sidebar/index.tsx | 11 ++++- .../src/app/chat/sidebar/projects/index.ts | 1 + .../sidebar/projects/workspace-groups.test.ts | 44 +++++++++++++++++++ .../chat/sidebar/projects/workspace-groups.ts | 38 ++++++++++++++-- 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 99d31d6cd2..b7bc8ae140 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -1045,8 +1045,15 @@ export function ChatSidebar({ // overlay always has a lane to place a missing in-project session into. const enteredProjectContent = useMemo( () => - enteredProject ? overlayLiveLanes(enteredProject, enteredProjectOverlaySessions, removedSessionIds) : undefined, - [enteredProject, enteredProjectOverlaySessions, removedSessionIds] + enteredProject + ? overlayLiveLanes( + enteredProject, + enteredProjectOverlaySessions, + removedSessionIds, + projectOwnerBySessionId(agentProjectTree) + ) + : undefined, + [enteredProject, enteredProjectOverlaySessions, removedSessionIds, agentProjectTree] ) const scopedRepoPaths = useMemo( diff --git a/apps/desktop/src/app/chat/sidebar/projects/index.ts b/apps/desktop/src/app/chat/sidebar/projects/index.ts index 9364b7096f..d4fb9dac7b 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/index.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/index.ts @@ -15,6 +15,7 @@ export { liveSessionProjectId, overlayLiveLanes, overlayLivePreviews, + projectOwnerBySessionId, reconcileEnteredProjectSessions, sessionBucketId, sessionMatchesProjectFilter, 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 a1b0c75aa7..8484b6909c 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 @@ -13,6 +13,7 @@ import { NO_PROJECT_ID, overlayLiveLanes, overlayLivePreviews, + projectOwnerBySessionId, reconcileEnteredProjectSessions, sessionMatchesProjectFilter, sessionProjectColor, @@ -615,6 +616,36 @@ describe('sessionProjectColor', () => { }) describe('overlayLiveLanes', () => { + it('does not inject a backend-owned sibling worktree session into an ancestor project', () => { + const sibling = makeCwdSession('/work/repos/app-2/src', { id: 'sibling', git_repo_root: null }) + + const ancestor = projectNode({ + id: 'p_work', + path: '/work', + repos: [{ id: '/work', label: 'work', path: '/work', groups: [], sessionCount: 0 }] + }) + + const repo = projectNode({ + id: 'p_app', + path: '/work/repos/app', + repos: [ + { + id: '/work/repos/app', + label: 'app', + path: '/work/repos/app', + groups: [lane({ id: '/work/repos/app-2', label: 'app-2', path: '/work/repos/app-2', sessions: [sibling] })], + sessionCount: 1 + } + ], + previewSessions: [sibling], + sessionCount: 1 + }) + + const overlaid = overlayLiveLanes(ancestor, [sibling], new Set(), projectOwnerBySessionId([ancestor, repo])) + + expect(overlaid.sessionCount).toBe(0) + }) + it('keeps an overview preview visible when the hydrated drill-in is stale', () => { const staleHistory = makeCwdSession('/www/app', { id: 'stale-history', @@ -1085,6 +1116,19 @@ describe('overlayLiveLanes', () => { }) describe('overlayLivePreviews', () => { + it('keeps a backend-owned sibling worktree session out of an ancestor preview when git_repo_root is null', () => { + const sibling = makeCwdSession('/work/repos/app-2/src', { id: 'sibling', git_repo_root: null }) + const ancestor = projectNode({ id: 'p_work', path: '/work', previewSessions: [], sessionCount: 0 }) + const repo = projectNode({ id: 'p_app', path: '/work/repos/app', previewSessions: [sibling], sessionCount: 1 }) + + const previews = overlayLivePreviews([ancestor, repo], [sibling], [ + makeProject('p_work', ['/work']), + makeProject('p_app', ['/work/repos/app']) + ], 3) + + expect(previews.p_work).toBeUndefined() + expect(previews.p_app?.map(session => session.id)).toEqual(['sibling']) + }) it('merges live sessions into a project preview, live first, capped to the limit', () => { const project = projectNode({ id: '/www/app', 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 3f457c885e..1c91a5acf4 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts @@ -476,6 +476,30 @@ export function sessionProjectColor(session: SessionInfo, projects: ProjectInfo[ return projects.find(project => project.id === projectId)?.color ?? null } +/** + * Membership in a project-tree snapshot is backend-resolved: in particular, + * the git probe can identify a sibling worktree even while its persisted row + * has not yet been backfilled with `git_repo_root`. Keep that answer when the + * live cache refreshes the same row instead of re-inferring ownership from its + * cwd (which can make an umbrella project claim it as well). + */ +export function projectOwnerBySessionId(projects: SidebarProjectTree[]): ReadonlyMap { + const owners = new Map() + + for (const project of projects) { + const sessions = [ + ...(project.previewSessions ?? []), + ...project.repos.flatMap(repo => repo.groups.flatMap(group => group.sessions)) + ] + + for (const session of sessions) { + owners.set(session.id, project.id) + } + } + + return owners +} + const upsertSession = (rows: SessionInfo[], session: SessionInfo): SessionInfo[] => [session, ...rows.filter(row => row.id !== session.id)].sort((a, b) => sessionRecency(b) - sessionRecency(a)) @@ -746,7 +770,8 @@ export function excludeProjectSessions( export function overlayLiveLanes( project: SidebarProjectTree, live: SessionInfo[], - removed: ReadonlySet = NO_REMOVED + removed: ReadonlySet = NO_REMOVED, + authoritativeOwners: ReadonlyMap = new Map() ): SidebarProjectTree { if (project.isNoProject) { return overlayHomeLane(project, live, removed) @@ -754,8 +779,14 @@ export function overlayLiveLanes( let changed = false + const projectLive = live.filter(session => { + const owner = authoritativeOwners.get(session.id) + + return !owner || owner === project.id + }) + const repos = project.repos.map(repo => { - const next = overlayRepoLanes(repo, live, removed) + const next = overlayRepoLanes(repo, projectLive, removed) changed ||= next !== repo @@ -804,13 +835,14 @@ export function overlayLivePreviews( { removed = NO_REMOVED, rankIds }: PreviewOverlayOptions = {} ): Record { const byProject = new Map() + const authoritativeOwners = projectOwnerBySessionId(projects) for (const session of live) { if (removed.has(session.id)) { continue } - const projectId = sessionBucketId(session, explicitProjects) + const projectId = authoritativeOwners.get(session.id) ?? sessionBucketId(session, explicitProjects) if (!projectId) { continue