From e1bfee7381f7ae730a6cfbb2daae1bba46ac9feb Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 10 Jun 2026 15:13:34 -0700 Subject: [PATCH] Group sidebar rows by project setup Co-authored-by: Orca --- notes/project-first-host-model.md | 6 +- .../src/components/sidebar/WorktreeList.tsx | 27 +++- .../sidebar/worktree-list-groups.test.ts | 124 ++++++++++++++ .../sidebar/worktree-list-groups.ts | 153 ++++++++++++++---- 4 files changed, 277 insertions(+), 33 deletions(-) diff --git a/notes/project-first-host-model.md b/notes/project-first-host-model.md index 53d657b9869..50da2fd8b15 100644 --- a/notes/project-first-host-model.md +++ b/notes/project-first-host-model.md @@ -680,10 +680,14 @@ Landed so far: - Hydrated renderer store `projects` and `projectHostSetups` alongside repos, with a repo-derived fallback for older preloads/runtimes that do not yet implement the project APIs. +- Updated the sidebar `groupBy: repo` row builder to group by durable project + identity when project/setup data exists, while keeping a repo-by-repo fallback + for projects that have not been linked across hosts. - Added tests for local repos, SSH repos, same-provider multi-host grouping, no-identity same-name non-grouping, selector cache behavior, persistence backfill, repo mutation synchronization, renderer hydration, and runtime RPC - routing. + routing. Sidebar row-builder tests now cover project-first multi-host grouping + and same-name repo separation without project identity. Important limitation: diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 27ee8832303..1f284e5a81d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -68,6 +68,7 @@ import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' import { type Row, + type ProjectGroupingModel, type WorktreeGroupBy, ALL_GROUP_KEY, PINNED_GROUP_KEY, @@ -416,6 +417,7 @@ type VirtualizedWorktreeViewportProps = { onHostDragActiveChange: (active: boolean) => void prCache: Record | null workspaceStatuses: readonly WorkspaceStatusDefinition[] + projectGrouping?: ProjectGroupingModel projectGroups?: readonly ProjectGroup[] onMoveWorktreeToStatus: (worktreeId: string, status: WorkspaceStatus) => void onMoveWorktreesToStatus: (worktreeIds: readonly string[], status: WorkspaceStatus) => void @@ -899,6 +901,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onHostDragActiveChange, prCache, workspaceStatuses, + projectGrouping, projectGroups = EMPTY_PROJECT_GROUPS, onMoveWorktreeToStatus, onMoveWorktreesToStatus, @@ -1590,7 +1593,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeMap, true, settings, - projectGroups + projectGroups, + new Set(), + new Map(), + [], + projectGrouping ).filter((r): r is Extract => r.type === 'item') if (worktreeRows.length === 0) { return @@ -1637,7 +1644,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeLineageById, worktreeMap, settings, - projectGroups + projectGroups, + projectGrouping ] ) @@ -4138,6 +4146,12 @@ const WorktreeList = React.memo(function WorktreeList({ // Why: manual repo header order is bound to state.repos. Recent/Smart derive // header order from the sorted visible worktree stream instead. const repos = useAppStore((s) => s.repos) + const projects = useAppStore((s) => s.projects) + const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const projectGrouping = useMemo( + () => ({ projects, projectHostSetups }), + [projectHostSetups, projects] + ) const projectGroups = useAppStore((s) => s.projectGroups ?? EMPTY_PROJECT_GROUPS) const effectiveCollapsedGroups = useMemo(() => { if (!agentSendTargetWorktreeId) { @@ -4158,7 +4172,8 @@ const WorktreeList = React.memo(function WorktreeList({ prCache, workspaceStatuses, settings, - projectGroups + projectGroups, + projectGrouping )) { next.delete(groupKey) } @@ -4188,6 +4203,7 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy, prCache, projectGroups, + projectGrouping, repoMap, settings, workspaceStatuses, @@ -4260,7 +4276,8 @@ const WorktreeList = React.memo(function WorktreeList({ projectGroups, placeholderRepoIds, importedWorktreesByRepo, - pendingCreations + pendingCreations, + projectGrouping ), [ groupBy, @@ -4275,6 +4292,7 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeMap, settings, projectGroups, + projectGrouping, placeholderRepoIds, importedWorktreesByRepo, pendingCreations @@ -4969,6 +4987,7 @@ const WorktreeList = React.memo(function WorktreeList({ onHostDragActiveChange={setHostDragActive} prCache={prCache} workspaceStatuses={workspaceStatuses} + projectGrouping={projectGrouping} projectGroups={projectGroups} onMoveWorktreeToStatus={moveWorktreeToStatus} onMoveWorktreesToStatus={moveWorktreesToStatus} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 6b9ae31c9db..4b61eb71b49 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -14,6 +14,8 @@ import { } from './worktree-list-groups' import type { DetectedWorktree, + Project, + ProjectHostSetup, Repo, ProjectGroup, Worktree, @@ -50,6 +52,59 @@ const worktree: Worktree = { const repoMap = new Map([[repo.id, repo]]) +const remoteRepo: Repo = { + id: 'repo-remote', + path: '/home/alice/orca', + displayName: 'orca', + badgeColor: '#111111', + addedAt: 1, + connectionId: 'gpu-vm' +} + +const remoteWorktree: Worktree = { + ...worktree, + id: 'wt-remote', + repoId: remoteRepo.id, + path: '/home/alice/orca-feature', + displayName: 'remote feature' +} + +const project: Project = { + id: 'github:stablyai/orca', + displayName: 'Orca', + badgeColor: '#737373', + sourceRepoIds: [repo.id, remoteRepo.id], + createdAt: 1, + updatedAt: 1 +} + +const projectHostSetups: ProjectHostSetup[] = [ + { + id: repo.id, + projectId: project.id, + hostId: 'local', + repoId: repo.id, + path: repo.path, + displayName: repo.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + }, + { + id: remoteRepo.id, + projectId: project.id, + hostId: 'ssh:gpu-vm', + repoId: remoteRepo.id, + path: remoteRepo.path, + displayName: remoteRepo.displayName, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } +] + function makeDetectedWorktree(overrides: Partial = {}): DetectedWorktree { return { ...worktree, @@ -253,6 +308,75 @@ describe('buildRows with pinned worktrees', () => { expect(rows[0]).toMatchObject({ type: 'header', label: 'c15t' }) }) + it('groups multiple host setups for the same project under one project header', () => { + const rows = buildRows( + 'repo', + [worktree, remoteWorktree], + new Map([ + [repo.id, repo], + [remoteRepo.id, remoteRepo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [worktree.id, worktree], + [remoteWorktree.id, remoteWorktree] + ]), + false, + undefined, + [], + new Set(), + new Map(), + [], + { projects: [project], projectHostSetups } + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 }, + { type: 'item', worktree: { id: worktree.id } }, + { type: 'item', worktree: { id: remoteWorktree.id } } + ]) + }) + + it('keeps same-named repos separate without project setup identity', () => { + const rows = buildRows( + 'repo', + [worktree, remoteWorktree], + new Map([ + [repo.id, { ...repo, displayName: 'orca' }], + [remoteRepo.id, { ...remoteRepo, displayName: 'orca' }] + ]), + null, + new Set() + ) + + expect(rows.filter((row) => row.type === 'header')).toMatchObject([ + { key: 'repo:repo-1' }, + { key: 'repo:repo-remote' } + ]) + }) + + it('returns project group keys for worktree reveal when project setup identity exists', () => { + expect( + getGroupKeyForWorktree( + 'repo', + remoteWorktree, + new Map([[remoteRepo.id, remoteRepo]]), + null, + undefined, + undefined, + { + projects: [project], + projectHostSetups + } + ) + ).toBe('project:github:stablyai/orca') + }) + it('emits an imported worktrees card at the top of repo-group rows', () => { const hidden = [ makeDetectedWorktree({ id: 'hidden-1', displayName: 'payments-refactor' }), diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 100499f7ae9..246bea0126b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -3,6 +3,8 @@ import { CircleX, FolderTree, List, Pin } from 'lucide-react' import type React from 'react' import type { DetectedWorktree, + Project, + ProjectHostSetup, Repo, ProjectGroup, ProjectOrderBy, @@ -98,7 +100,61 @@ function buildPendingCreationRow( } } -type OrderedGroupEntry = [string, { label: string; items: Worktree[]; repo?: Repo }] +type OrderedGroupEntry = [string, WorktreeGroupEntry] + +export type ProjectGroupingModel = { + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] +} + +type WorktreeGroupEntry = { + label: string + items: Worktree[] + repo?: Repo + repoIds: Set +} + +type ProjectGroupingIndex = { + projectById: Map + setupByRepoId: Map +} + +function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null { + if (!model || model.projects.length === 0 || model.projectHostSetups.length === 0) { + return null + } + return { + projectById: new Map(model.projects.map((project) => [project.id, project])), + setupByRepoId: new Map(model.projectHostSetups.map((setup) => [setup.repoId, setup])) + } +} + +function getProjectGroupingForRepo( + repoId: string, + repoMap: Map, + projectIndex: ProjectGroupingIndex | null +): { key: string; label: string; repo?: Repo; projectId?: string } { + const repo = repoMap.get(repoId) + const setup = projectIndex?.setupByRepoId.get(repoId) + const project = setup ? projectIndex?.projectById.get(setup.projectId) : undefined + if (!setup || !project) { + return { + key: `repo:${repoId}`, + label: repo?.displayName ?? 'Unknown', + repo + } + } + return { + key: `project:${project.id}`, + label: project.displayName, + repo, + projectId: project.id + } +} + +function addRepoIdToGroup(group: WorktreeGroupEntry, repoId: string): void { + group.repoIds.add(repoId) +} export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed' @@ -540,9 +596,11 @@ export function buildRows( projectGroups: readonly ProjectGroup[] = [], placeholderRepoIds: ReadonlySet = new Set(), importedWorktreesByRepo: ReadonlyMap = new Map(), - pendingCreations: readonly PendingCreationRef[] = [] + pendingCreations: readonly PendingCreationRef[] = [], + projectGrouping?: ProjectGroupingModel ): Row[] { const result: Row[] = [] + const projectIndex = buildProjectGroupingIndex(projectGrouping) const pendingByRepo = new Map() for (const creation of pendingCreations) { @@ -597,15 +655,16 @@ export function buildRows( return result } - const grouped = new Map() + const grouped = new Map() for (const w of unpinned) { let key: string let label: string let repo: Repo | undefined if (groupBy === 'repo') { - repo = repoMap.get(w.repoId) - key = `repo:${w.repoId}` - label = repo?.displayName ?? 'Unknown' + const grouping = getProjectGroupingForRepo(w.repoId, repoMap, projectIndex) + key = grouping.key + label = grouping.label + repo = grouping.repo } else if (groupBy === 'workspace-status') { const workspaceStatus = getWorkspaceStatus(w, workspaceStatuses) key = getWorkspaceStatusGroupKey(workspaceStatus) @@ -617,45 +676,65 @@ export function buildRows( label = PR_GROUP_META[prGroup].label } if (!grouped.has(key)) { - grouped.set(key, { label, items: [], repo }) + grouped.set(key, { label, items: [], repo, repoIds: new Set() }) } - grouped.get(key)!.items.push(w) + const group = grouped.get(key)! + group.items.push(w) + addRepoIdToGroup(group, w.repoId) } if (groupBy === 'repo') { for (const repoId of placeholderRepoIds) { - const repo = repoMap.get(repoId) - if (!repo) { + const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) + if (!grouping.repo) { continue } - const key = `repo:${repoId}` + const key = grouping.key if (!grouped.has(key)) { // Why: repos can arrive before worktree scans, but stale IDs passed by // older snapshots must not render an "Unknown" project header. - grouped.set(key, { label: repo.displayName, items: [], repo }) + grouped.set(key, { + label: grouping.label, + items: [], + repo: grouping.repo, + repoIds: new Set([repoId]) + }) + } else { + addRepoIdToGroup(grouped.get(key)!, repoId) } } } if (groupBy === 'repo') { for (const [repoId, candidate] of importedWorktreesByRepo) { - const key = `repo:${repoId}` + const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) + const key = grouping.key if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) { grouped.set(key, { - label: candidate.repo.displayName, + label: grouping.label, items: [], - repo: candidate.repo + repo: grouping.repo ?? candidate.repo, + repoIds: new Set([repoId]) }) + } else if (grouped.has(key)) { + addRepoIdToGroup(grouped.get(key)!, repoId) } } } if (groupBy === 'repo') { for (const repoId of pendingByRepo.keys()) { - const key = `repo:${repoId}` + const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex) + const key = grouping.key if (!grouped.has(key)) { // Why: creating the first worktree in a repo leaves it with no group yet; // ensure one so the in-progress row nests under its repo instead of being // dropped. - const repo = repoMap.get(repoId) - grouped.set(key, { label: repo?.displayName ?? 'Unknown', items: [], repo }) + grouped.set(key, { + label: grouping.label, + items: [], + repo: grouping.repo, + repoIds: new Set([repoId]) + }) + } else { + addRepoIdToGroup(grouped.get(key)!, repoId) } } } @@ -743,16 +822,27 @@ export function buildRows( result.push(header) if (!isCollapsed) { if (groupBy === 'repo') { - const repoId = repo?.id ?? key.slice('repo:'.length) - const candidate = importedWorktreesByRepo.get(repoId) - if (candidate) { - result.push(buildImportedWorktreesCardRow(candidate, 'repo-group')) + const repoIds = + group.repoIds.size > 0 + ? [...group.repoIds] + : repo + ? [repo.id] + : key.startsWith('repo:') + ? [key.slice('repo:'.length)] + : [] + for (const repoId of repoIds) { + const candidate = importedWorktreesByRepo.get(repoId) + if (candidate) { + result.push(buildImportedWorktreesCardRow(candidate, 'repo-group')) + } } // Why: surface in-progress creates at the top of their own repo so the // new workspace appears where it will land, not flashed to the very top // of the sidebar. - for (const creation of pendingByRepo.get(repoId) ?? []) { - result.push(buildPendingCreationRow(creation, repoMap)) + for (const repoId of repoIds) { + for (const creation of pendingByRepo.get(repoId) ?? []) { + result.push(buildPendingCreationRow(creation, repoMap)) + } } } const items = groupBy === 'repo' ? orderMainWorktreeFirst(group.items) : group.items @@ -869,7 +959,8 @@ export function getGroupKeyForWorktree( repoMap: Map, prCache: Record | null, workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), - settings?: AppState['settings'] + settings?: AppState['settings'], + projectGrouping?: ProjectGroupingModel ): string | null { if (groupBy === 'none') { return ALL_GROUP_KEY @@ -878,7 +969,11 @@ export function getGroupKeyForWorktree( return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses)) } if (groupBy === 'repo') { - return `repo:${worktree.repoId}` + return getProjectGroupingForRepo( + worktree.repoId, + repoMap, + buildProjectGroupingIndex(projectGrouping) + ).key } return `pr:${getPRGroupKey(worktree, repoMap, prCache, settings)}` } @@ -890,7 +985,8 @@ export function getGroupKeysForWorktree( prCache: Record | null, workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), settings?: AppState['settings'], - projectGroups: readonly ProjectGroup[] = [] + projectGroups: readonly ProjectGroup[] = [], + projectGrouping?: ProjectGroupingModel ): string[] { const groupKey = getGroupKeyForWorktree( groupBy, @@ -898,7 +994,8 @@ export function getGroupKeysForWorktree( repoMap, prCache, workspaceStatuses, - settings + settings, + projectGrouping ) if (!groupKey) { return []