mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Group sidebar rows by project setup
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user