Group sidebar rows by project setup

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:33:58 -07:00
co-authored by Orca
parent a730ba0b8d
commit e1bfee7381
4 changed files with 277 additions and 33 deletions
@@ -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<string, unknown> | 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<Row, { type: 'item' }> => 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}
@@ -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> = {}): 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' }),
@@ -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<string>
}
type ProjectGroupingIndex = {
projectById: Map<string, Project>
setupByRepoId: Map<string, ProjectHostSetup>
}
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<string, Repo>,
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<string> = new Set(),
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate> = new Map(),
pendingCreations: readonly PendingCreationRef[] = []
pendingCreations: readonly PendingCreationRef[] = [],
projectGrouping?: ProjectGroupingModel
): Row[] {
const result: Row[] = []
const projectIndex = buildProjectGroupingIndex(projectGrouping)
const pendingByRepo = new Map<string, PendingCreationRef[]>()
for (const creation of pendingCreations) {
@@ -597,15 +655,16 @@ export function buildRows(
return result
}
const grouped = new Map<string, { label: string; items: Worktree[]; repo?: Repo }>()
const grouped = new Map<string, WorktreeGroupEntry>()
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<string, Repo>,
prCache: Record<string, unknown> | 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<string, unknown> | 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 []