Fix pinned sidebar order and render churn

This commit is contained in:
Brennan Benson
2026-07-16 19:38:13 -07:00
parent db18f20c59
commit c43c233a07
9 changed files with 212 additions and 477 deletions
@@ -34,7 +34,6 @@ import {
useWorktreeMap
} from '@/store/selectors'
import WorktreeCard, { type ActiveSurfaceVariant } from './WorktreeCard'
import StatusIndicator from './StatusIndicator'
import { WorktreeSidebarDropIndicator } from './WorktreeSidebarDropIndicator'
import {
getProjectGroupHeaderSectionEndByGroupId,
@@ -125,11 +124,8 @@ import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state'
import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos'
import {
getVisibleWorktreeBrowserActivityTabs,
getVisibleWorktreeTerminalActivityTabs,
getWorktreeSectionTerminalActivityTabs
getVisibleWorktreeTerminalActivityTabs
} from './visible-worktree-activity-inputs'
import { selectTerminalLayoutRootsForWorktrees } from './worktree-card-status-inputs'
import { buildWorktreeSectionActivitySummaries } from './worktree-section-activity'
import { selectWorktreeListReviewCacheInputs } from './worktree-list-review-cache-inputs'
import {
VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT,
@@ -295,6 +291,10 @@ import {
getProjectGroupExecutionHostIdForRows
} from './worktree-list-host-filtering'
import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display'
import {
getPreferredWorktreeRows,
getRenderedWorktreesInSidebarOrder
} from './worktree-sidebar-row-preference'
export {
getScrollTopToRevealBounds,
@@ -743,27 +743,6 @@ function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element {
)
}
function SectionRunningBadge({ count }: { count: number }): React.JSX.Element {
const label = formatSectionActivityLabel(count, 'running workspace')
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex h-4 shrink-0 items-center gap-1 rounded-full border border-worktree-sidebar-border bg-worktree-sidebar-accent px-1.5 text-[9px] font-medium leading-none text-muted-foreground/90"
aria-label={label}
>
<StatusIndicator status="working" aria-hidden="true" className="size-2.5" />
{count}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{label}
</TooltipContent>
</Tooltip>
)
}
function HostHeaderHealthIcon({
health
}: {
@@ -1090,41 +1069,6 @@ function getRenderRowWorktreeItem(row: RenderRow, worktreeId: string): WorktreeI
return row.type === 'item' && row.worktree.id === worktreeId ? row : null
}
function getPreferredWorktreeRows(
rows: readonly WorktreeItemRow[],
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
): WorktreeItemRow[] {
// Why: when pinned rows are duplicated into natural groups, navigation and
// selection should prefer the natural-group copy and only fall back to pinned.
if (pinnedDisplayPolicy === 'single-location') {
const seen = new Set<string>()
return rows.filter((row) => {
if (seen.has(row.worktree.id)) {
return false
}
seen.add(row.worktree.id)
return true
})
}
const preferredRows: WorktreeItemRow[] = []
const seen = new Set<string>()
for (const row of rows) {
if (isPinnedWorktreeRow(row) || seen.has(row.worktree.id)) {
continue
}
preferredRows.push(row)
seen.add(row.worktree.id)
}
for (const row of rows) {
if (seen.has(row.worktree.id)) {
continue
}
preferredRows.push(row)
seen.add(row.worktree.id)
}
return preferredRows
}
function getRenderRowOptionId(
row: RenderRow | undefined,
worktreeId?: string | null
@@ -4457,9 +4401,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
<div className="min-w-0 truncate text-[13px] font-semibold leading-none">
{row.label}
</div>
{isHeaderCollapsed && row.runningCount && row.runningCount > 0 ? (
<SectionRunningBadge count={row.runningCount} />
) : null}
<RepoForkIndicator upstream={row.repo?.upstream} />
<FolderPathStatusIndicator status={projectGroupPathStatus} />
</div>
@@ -5415,20 +5356,6 @@ const WorktreeList = React.memo(function WorktreeList({
)
const settings = useAppStore((s) => s.settings)
const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings)
const sectionActivitySourceState = useAppStore(
useShallow((s) => ({
tabsByWorktree: getWorktreeSectionTerminalActivityTabs(s.tabsByWorktree),
browserTabsByWorktree: getVisibleWorktreeBrowserActivityTabs(s.browserTabsByWorktree),
ptyIdsByTabId: s.ptyIdsByTabId,
runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId,
terminalLayoutsByTabId: s.terminalLayoutsByTabId,
agentStatusEpoch: s.agentStatusEpoch,
agentStatusByPaneKey: s.agentStatusByPaneKey,
migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId,
retainedAgentsByPaneKey: s.retainedAgentsByPaneKey,
runtimeAgentOrchestrationByPaneKey: s.runtimeAgentOrchestrationByPaneKey
}))
)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
@@ -5929,49 +5856,6 @@ const WorktreeList = React.memo(function WorktreeList({
() => new Map(hostOptions.map((host) => [host.id, host.label])),
[hostOptions]
)
const sectionActivityWorktreeIds = useMemo(
() => worktrees.map((worktree) => worktree.id),
[worktrees]
)
const sectionActivityState = useMemo(
() => ({
...sectionActivitySourceState,
terminalLayoutRootsByTabId: selectTerminalLayoutRootsForWorktrees(
sectionActivitySourceState,
sectionActivityWorktreeIds
)
}),
[sectionActivitySourceState, sectionActivityWorktreeIds]
)
const sectionActivitySummaries = useMemo(
() =>
buildWorktreeSectionActivitySummaries({
groupBy,
worktrees,
repoMap,
prCache,
workspaceStatuses,
settings,
projectGroups: visibleProjectGroupsForRows,
projectGrouping,
pinnedDisplayPolicy,
defaultHostId,
state: sectionActivityState
}),
[
defaultHostId,
groupBy,
pinnedDisplayPolicy,
prCache,
projectGrouping,
repoMap,
sectionActivityState,
settings,
visibleProjectGroupsForRows,
workspaceStatuses,
worktrees
]
)
// Build flat row list for rendering
const rows: Row[] = useMemo(
@@ -5998,8 +5882,7 @@ const WorktreeList = React.memo(function WorktreeList({
visibleFolderWorkspacesForRows,
hostLabelById,
defaultHostId,
pinnedDisplayPolicy,
sectionActivitySummaries
pinnedDisplayPolicy
),
[
groupBy,
@@ -6022,8 +5905,7 @@ const WorktreeList = React.memo(function WorktreeList({
newExternalWorktreesInboxByRepo,
pendingCreations,
hostLabelById,
pinnedDisplayPolicy,
sectionActivitySummaries
pinnedDisplayPolicy
]
)
const orderedHostOptions = useMemo(
@@ -6105,23 +5987,10 @@ const WorktreeList = React.memo(function WorktreeList({
// 'pr-status') can reorder cards into grouped sections. Using the flat
// order would cause Cmd+1–9 shortcuts to not match the visual card
// positions when grouping is active.
const renderedWorktrees = useMemo(() => {
const itemRows: WorktreeItemRow[] = []
const folderRows: Worktree[] = []
for (const row of sectionRows) {
if (row.type === 'item') {
itemRows.push(row)
continue
}
if (row.type === 'folder-workspace') {
folderRows.push(folderWorkspaceToWorktree(row.folderWorkspace))
}
}
return [
...getPreferredWorktreeRows(itemRows, pinnedDisplayPolicy).map((row) => row.worktree),
...folderRows
]
}, [pinnedDisplayPolicy, sectionRows])
const renderedWorktrees = useMemo(
() => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy),
[pinnedDisplayPolicy, sectionRows]
)
const renderedWorktreeIds = useMemo(
() => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)),
[renderedWorktrees]
@@ -444,9 +444,7 @@ describe('addHostSectionRows', () => {
count: 1,
hostWorktreeCounts: new Map([[localHostId, 1]]),
hostWorktreeIds: new Map([[localHostId, ['local-wt']]]),
worktreeIds: ['local-wt'],
runningCount: 1,
hostRunningCounts: new Map([[localHostId, 1]])
worktreeIds: ['local-wt']
},
repoHeader(ssh),
item('ssh-wt', ssh)
@@ -480,7 +478,7 @@ describe('addHostSectionRows', () => {
{ hostId: 'ssh:ssh-1', count: 1 }
])
expect(sectioned.filter((row) => row.type === 'header' && row.key === 'all')).toMatchObject([
{ hostId: 'local', count: 1, runningCount: 1, worktreeIds: ['local-wt'] }
{ hostId: 'local', count: 1, worktreeIds: ['local-wt'] }
])
})
@@ -146,13 +146,11 @@ function localizePendingRowsForHost(
}
const count = row.hostWorktreeCounts.get(hostId)
if (count !== undefined && count > 0) {
const runningCount = row.hostRunningCounts?.get(hostId)
localized.push({
...row,
count,
hostId,
worktreeIds: row.hostWorktreeIds?.get(hostId) ?? row.worktreeIds,
runningCount: runningCount && runningCount > 0 ? runningCount : undefined
worktreeIds: row.hostWorktreeIds?.get(hostId) ?? row.worktreeIds
})
}
}
@@ -219,13 +217,11 @@ export function addHostSectionRows(args: {
}
const hostRows = rowsByHostId.get(hostId) ?? []
const hostIds = row.hostWorktreeIds?.get(hostId)
const runningCount = row.hostRunningCounts?.get(hostId)
hostRows.push({
...row,
count,
hostId,
worktreeIds: hostIds ?? row.worktreeIds,
runningCount: runningCount && runningCount > 0 ? runningCount : undefined
worktreeIds: hostIds ?? row.worktreeIds
})
rowsByHostId.set(hostId, hostRows)
}
@@ -2176,64 +2176,6 @@ describe('project groups', () => {
})
})
it('attaches activity summaries to Project Group headers', () => {
const group: ProjectGroup = {
id: 'group-1',
name: 'Platform',
parentPath: '/platform',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const groupedRepo: Repo = { ...repo, projectGroupId: group.id }
const hostRunningCounts = new Map([['local', 1] as const])
const rows = buildRows(
'repo',
[],
new Map([[groupedRepo.id, groupedRepo]]),
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map(),
false,
undefined,
[group],
new Set([groupedRepo.id]),
new Map(),
new Map(),
[],
undefined,
[],
undefined,
'local',
'single-location',
new Map([
[
'project-group:group-1',
{
runningCount: 1,
hostRunningCounts
}
]
])
)
expect(rows[0]).toMatchObject({
type: 'header',
key: 'project-group:group-1',
runningCount: 1,
hostRunningCounts
})
})
it('does not resurrect filtered repos as empty Project Group headers', () => {
const group: ProjectGroup = {
id: 'group-1',
@@ -34,7 +34,6 @@ import type { AppState } from '../../store/types'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key'
import { getRepoDisplayLabelKey, getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels'
import { translate } from '@/i18n/i18n'
import type { WorktreeSectionActivitySummary } from './worktree-section-activity'
import {
getExecutionHostLabel,
LOCAL_EXECUTION_HOST_ID,
@@ -69,8 +68,6 @@ export type GroupHeaderRow = {
hostWorktreeCounts?: ReadonlyMap<ExecutionHostId, number>
hostWorktreeIds?: ReadonlyMap<ExecutionHostId, readonly string[]>
worktreeIds?: readonly string[]
runningCount?: number
hostRunningCounts?: ReadonlyMap<ExecutionHostId, number>
}
export type WorktreeRow = {
@@ -468,8 +465,7 @@ function emitPinnedGroup(
renderedNaturalAnchorRepoIds: ReadonlySet<string>,
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>,
allowImportedFallback: boolean,
result: Row[],
activitySummary?: WorktreeSectionActivitySummary
result: Row[]
): void {
const pinned = worktrees.filter((w) => w.isPinned)
if (pinned.length === 0) {
@@ -500,9 +496,7 @@ function emitPinnedGroup(
icon: PINNED_GROUP_META.icon,
hostWorktreeCounts,
hostWorktreeIds,
worktreeIds: pinned.map((worktree) => worktree.id),
runningCount: activitySummary?.runningCount,
hostRunningCounts: activitySummary?.hostRunningCounts
worktreeIds: pinned.map((worktree) => worktree.id)
})
if (collapsedGroups.has(PINNED_GROUP_KEY)) {
for (const repoId of pinnedRepoOrder) {
@@ -1014,8 +1008,7 @@ export function buildRows(
folderWorkspaces: readonly FolderWorkspace[] = [],
hostLabelById?: ReadonlyMap<string, string>,
defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID,
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings),
sectionActivitySummaries?: ReadonlyMap<string, WorktreeSectionActivitySummary>
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings)
): Row[] {
const result: Row[] = []
const projectIndex = buildProjectGroupingIndex(projectGrouping)
@@ -1050,14 +1043,6 @@ export function buildRows(
settings,
projectGrouping
})
const getSectionActivity = (
key: string
): Pick<GroupHeaderRow, 'runningCount' | 'hostRunningCounts'> => {
const summary = sectionActivitySummaries?.get(key)
return summary && summary.runningCount > 0
? { runningCount: summary.runningCount, hostRunningCounts: summary.hostRunningCounts }
: {}
}
// Repos whose only visible worktrees are pinned are surfaced via the pinned
// section, so the repo-grouped inbox loop below must not re-create a group for them.
const visiblePinnedRepoIds = new Set(
@@ -1071,8 +1056,7 @@ export function buildRows(
renderedNaturalAnchorRepoIds,
importedWorktreesByRepo,
groupBy !== 'repo',
result,
sectionActivitySummaries?.get(PINNED_GROUP_KEY)
result
)
if (groupBy === 'none') {
if (naturalWorktrees.length > 0) {
@@ -1085,8 +1069,7 @@ export function buildRows(
icon: ALL_GROUP_META.icon,
hostWorktreeCounts: getHostWorktreeCounts(naturalWorktrees, repoMap, defaultHostId),
hostWorktreeIds: getHostWorktreeIds(naturalWorktrees, repoMap, defaultHostId),
worktreeIds: naturalWorktrees.map((worktree) => worktree.id),
...getSectionActivity(ALL_GROUP_KEY)
worktreeIds: naturalWorktrees.map((worktree) => worktree.id)
})
if (!collapsedGroups.has(ALL_GROUP_KEY)) {
appendWorktreeRows(result, naturalWorktrees, repoMap, lineageById, worktreeMap, {
@@ -1273,8 +1256,7 @@ export function buildRows(
icon: meta.icon,
hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId),
hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId),
worktreeIds: group.items.map((worktree) => worktree.id),
...getSectionActivity(key)
worktreeIds: group.items.map((worktree) => worktree.id)
}
})()
: (() => {
@@ -1289,8 +1271,7 @@ export function buildRows(
icon: meta.icon,
hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId),
hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId),
worktreeIds: group.items.map((worktree) => worktree.id),
...getSectionActivity(key)
worktreeIds: group.items.map((worktree) => worktree.id)
}
})()
@@ -1438,8 +1419,7 @@ export function buildRows(
tone: PROJECT_GROUP_META.tone,
icon: PROJECT_GROUP_META.icon,
projectGroup,
projectGroupDepth: depth,
...getSectionActivity(key)
projectGroupDepth: depth
})
if (!collapsedGroups.has(key)) {
for (const folderWorkspace of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) {
@@ -1,19 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type {
Project,
ProjectGroup,
ProjectHostSetup,
Repo,
TerminalTab,
Worktree
} from '../../../../shared/types'
import type { ProjectGroup, Repo, TerminalTab, Worktree } from '../../../../shared/types'
import {
getProjectGroupHeaderKey,
PINNED_GROUP_KEY,
type PinnedWorktreeDisplayPolicy,
type ProjectGroupingModel,
type WorktreeGroupBy
} from './worktree-list-groups'
import {
@@ -100,7 +91,6 @@ function makeState(
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {},
runtimeAgentOrchestrationByPaneKey: {},
...overrides
}
}
@@ -110,18 +100,12 @@ function buildSummaries({
worktrees,
repos,
projectGroups = [],
projectGrouping,
pinnedDisplayPolicy,
defaultHostId,
state
}: {
groupBy?: WorktreeGroupBy
worktrees: readonly Worktree[]
repos: readonly Repo[]
projectGroups?: readonly ProjectGroup[]
projectGrouping?: ProjectGroupingModel
pinnedDisplayPolicy?: PinnedWorktreeDisplayPolicy
defaultHostId?: Parameters<typeof buildWorktreeSectionActivitySummaries>[0]['defaultHostId']
state: WorktreeSectionActivityState
}) {
return buildWorktreeSectionActivitySummaries({
@@ -131,9 +115,6 @@ function buildSummaries({
prCache: null,
workspaceStatuses: [],
projectGroups,
projectGrouping,
pinnedDisplayPolicy,
defaultHostId,
state
})
}
@@ -167,20 +148,17 @@ describe('buildWorktreeSectionActivitySummaries', () => {
})
expect(summaries.get(getProjectGroupHeaderKey(parent.id))).toEqual({
runningCount: 1,
hostRunningCounts: new Map([['local', 1]])
runningCount: 1
})
expect(summaries.get(getProjectGroupHeaderKey(child.id))).toEqual({
runningCount: 1,
hostRunningCounts: new Map([['local', 1]])
runningCount: 1
})
expect(summaries.get(`repo:${repo.id}`)).toEqual({
runningCount: 1,
hostRunningCounts: new Map([['local', 1]])
runningCount: 1
})
})
it('counts pinned workspace activity only on the pinned header', () => {
it('counts pinned workspace activity on pinned and natural headers', () => {
const repo = makeRepo({ projectGroupId: 'group-1' })
const worktree = makeWorktree({ repoId: repo.id, isPinned: true })
const now = Date.now()
@@ -209,170 +187,14 @@ describe('buildWorktreeSectionActivitySummaries', () => {
state
})
const pinnedSummary = summaries.get(PINNED_GROUP_KEY)
expect(pinnedSummary?.runningCount).toBe(1)
expect(pinnedSummary?.hostRunningCounts).toEqual(new Map([['local', 1]]))
expect(summaries.get(`repo:${repo.id}`)).toBeUndefined()
expect(summaries.get(getProjectGroupHeaderKey('group-1'))).toBeUndefined()
})
it('counts pinned activity in natural groups when duplicate display is enabled', () => {
const repo = makeRepo({ projectGroupId: 'group-1' })
const worktree = makeWorktree({ repoId: repo.id, isPinned: true })
const now = Date.now()
const entry: AgentStatusEntry = {
state: 'working',
prompt: '',
updatedAt: now,
stateStartedAt: now,
paneKey: makePaneKey('tab-1', LEAF_ID),
stateHistory: []
}
const state = makeState({
tabsByWorktree: {
[worktree.id]: [makeTerminalTab({ id: 'tab-1', worktreeId: worktree.id })]
},
agentStatusEpoch: 1,
agentStatusByPaneKey: {
[entry.paneKey]: entry
}
expect(summaries.get(PINNED_GROUP_KEY)).toEqual({
runningCount: 1
})
const summaries = buildSummaries({
worktrees: [worktree],
repos: [repo],
projectGroups: [makeProjectGroup({ id: 'group-1' })],
pinnedDisplayPolicy: 'duplicate-in-groups',
state
expect(summaries.get(`repo:${repo.id}`)).toEqual({
runningCount: 1
})
expect(summaries.get(PINNED_GROUP_KEY)?.runningCount).toBe(1)
expect(summaries.get(`repo:${repo.id}`)?.runningCount).toBe(1)
expect(summaries.get(`repo:${repo.id}`)?.hostRunningCounts).toEqual(new Map([['local', 1]]))
expect(summaries.get(getProjectGroupHeaderKey('group-1'))?.runningCount).toBe(1)
expect(summaries.get(getProjectGroupHeaderKey('group-1'))?.hostRunningCounts).toEqual(
new Map([['local', 1]])
)
})
it('counts duplicate-mode pinned activity on project setup headers', () => {
const repo = makeRepo()
const project: Project = {
id: 'github:stablyai/orca',
displayName: 'Orca',
badgeColor: '#737373',
sourceRepoIds: [repo.id],
createdAt: 1,
updatedAt: 1
}
const setup: 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
}
const worktree = makeWorktree({ repoId: repo.id, isPinned: true })
const state = makeState({
tabsByWorktree: {
[worktree.id]: [
makeTerminalTab({ id: 'tab-1', worktreeId: worktree.id, title: 'Codex working' })
]
},
ptyIdsByTabId: {
'tab-1': ['pty-1']
}
expect(summaries.get(getProjectGroupHeaderKey('group-1'))).toEqual({
runningCount: 1
})
const summaries = buildSummaries({
worktrees: [worktree],
repos: [repo],
projectGrouping: { projects: [project], projectHostSetups: [setup] },
pinnedDisplayPolicy: 'duplicate-in-groups',
state
})
expect(summaries.get(PINNED_GROUP_KEY)?.runningCount).toBe(1)
expect(summaries.get(`project:${project.id}`)).toEqual({
runningCount: 1,
hostRunningCounts: new Map([['local', 1]])
})
expect(summaries.get(`repo:${repo.id}`)).toBeUndefined()
})
it('keeps running activity attributable to owning hosts per section', () => {
const localRepo = makeRepo({ id: 'local-repo' })
const sshRepo = makeRepo({
id: 'ssh-repo',
connectionId: 'ssh-1'
})
const localWorktree = makeWorktree({
id: 'local-wt',
repoId: localRepo.id,
isPinned: true
})
const sshWorktree = makeWorktree({
id: 'ssh-wt',
repoId: sshRepo.id,
isPinned: true
})
const state = makeState({
tabsByWorktree: {
[localWorktree.id]: [
makeTerminalTab({
id: 'local-tab',
worktreeId: localWorktree.id,
title: 'Codex working'
})
],
[sshWorktree.id]: [
makeTerminalTab({
id: 'ssh-tab',
worktreeId: sshWorktree.id,
title: 'Codex working'
})
]
},
ptyIdsByTabId: {
'local-tab': ['local-pty'],
'ssh-tab': ['ssh-pty']
}
})
const summaries = buildSummaries({
worktrees: [localWorktree, sshWorktree],
repos: [localRepo, sshRepo],
defaultHostId: 'local',
state
})
const pinnedSummary = summaries.get(PINNED_GROUP_KEY)
expect(pinnedSummary?.runningCount).toBe(2)
expect(pinnedSummary?.hostRunningCounts).toEqual(
new Map([
['local', 1],
['ssh:ssh-1', 1]
])
)
const allSummary = buildSummaries({
groupBy: 'none',
worktrees: [localWorktree, sshWorktree],
repos: [localRepo, sshRepo],
pinnedDisplayPolicy: 'duplicate-in-groups',
defaultHostId: 'local',
state
}).get('all')
expect(allSummary?.runningCount).toBe(2)
expect(allSummary?.hostRunningCounts).toEqual(
new Map([
['local', 1],
['ssh:ssh-1', 1]
])
)
})
})
@@ -8,17 +8,9 @@ import type {
Worktree,
WorkspaceStatusDefinition
} from '../../../../shared/types'
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
type ExecutionHostId
} from '../../../../shared/execution-host'
import {
getGroupKeysForWorktree,
getPinnedWorktreeDisplayPolicy,
type ProjectGroupingModel,
type WorktreeGroupBy,
type PinnedWorktreeDisplayPolicy,
PINNED_GROUP_KEY
} from './worktree-list-groups'
import {
@@ -36,7 +28,6 @@ export type WorktreeSectionActivityState = Pick<
| 'agentStatusByPaneKey'
| 'migrationUnsupportedByPtyId'
| 'retainedAgentsByPaneKey'
| 'runtimeAgentOrchestrationByPaneKey'
> & {
tabsByWorktree: Record<string, readonly Pick<TerminalTab, 'id' | 'title'>[]>
browserTabsByWorktree: Record<string, readonly BrowserActivityTab[]>
@@ -45,7 +36,6 @@ export type WorktreeSectionActivityState = Pick<
export type WorktreeSectionActivitySummary = {
runningCount: number
hostRunningCounts?: ReadonlyMap<ExecutionHostId, number>
}
export const EMPTY_WORKTREE_SECTION_ACTIVITY: WorktreeSectionActivitySummary = {
@@ -60,9 +50,6 @@ export function buildWorktreeSectionActivitySummaries({
workspaceStatuses,
settings,
projectGroups,
projectGrouping,
pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings),
defaultHostId = LOCAL_EXECUTION_HOST_ID,
state
}: {
groupBy: WorktreeGroupBy
@@ -72,30 +59,23 @@ export function buildWorktreeSectionActivitySummaries({
workspaceStatuses: readonly WorkspaceStatusDefinition[]
settings?: AppState['settings']
projectGroups: readonly ProjectGroup[]
projectGrouping?: ProjectGroupingModel
pinnedDisplayPolicy?: PinnedWorktreeDisplayPolicy
defaultHostId?: ExecutionHostId
state: WorktreeSectionActivityState
}): Map<string, WorktreeSectionActivitySummary> {
const summaries = new Map<string, WorktreeSectionActivitySummary>()
for (const worktree of worktrees) {
const naturalGroupKeys = getGroupKeysForWorktree(
groupBy,
worktree,
repoMap,
prCache,
workspaceStatuses,
settings,
projectGroups,
projectGrouping
)
const groupKeys =
worktree.isPinned && pinnedDisplayPolicy === 'single-location'
? [PINNED_GROUP_KEY]
: worktree.isPinned
? [PINNED_GROUP_KEY, ...naturalGroupKeys]
: naturalGroupKeys
const groupKeys = [
...(worktree.isPinned ? [PINNED_GROUP_KEY] : []),
...getGroupKeysForWorktree(
groupBy,
worktree,
repoMap,
prCache,
workspaceStatuses,
settings,
projectGroups
)
]
if (groupKeys.length === 0) {
continue
}
@@ -105,10 +85,6 @@ export function buildWorktreeSectionActivitySummaries({
const summary = summaries.get(groupKey) ?? { ...EMPTY_WORKTREE_SECTION_ACTIVITY }
if (status === 'working') {
summary.runningCount++
const hostRunningCounts = new Map(summary.hostRunningCounts ?? [])
const hostId = getWorktreeHostId(worktree, repoMap, defaultHostId)
hostRunningCounts.set(hostId, (hostRunningCounts.get(hostId) ?? 0) + 1)
summary.hostRunningCounts = hostRunningCounts
}
summaries.set(groupKey, summary)
}
@@ -117,20 +93,6 @@ export function buildWorktreeSectionActivitySummaries({
return summaries
}
function getWorktreeHostId(
worktree: Worktree,
repoMap: ReadonlyMap<string, Repo>,
defaultHostId: ExecutionHostId
): ExecutionHostId {
const repo = repoMap.get(worktree.repoId)
// Why: pinned headers can be cloned inside host sections, so hidden pinned
// activity must stay attributed to the owning host instead of the global key.
if (repo?.connectionId || repo?.executionHostId) {
return getRepoExecutionHostId(repo)
}
return defaultHostId
}
function getSectionWorktreeStatus(
state: WorktreeSectionActivityState,
worktreeId: string
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types'
import type { HostSectionRow } from './host-section-rows'
import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference'
const repo: Repo = {
id: 'repo-1',
path: '/repo-1',
displayName: 'Repo 1',
badgeColor: '#737373',
addedAt: 1
}
function worktree(id: string, isPinned = false): Worktree {
return {
id,
repoId: repo.id,
path: `/repo-1/${id}`,
displayName: id,
branch: id,
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned,
sortOrder: 1,
lastActivityAt: 1
}
}
function item(workspace: Worktree, sectionKey: string): HostSectionRow {
return {
type: 'item',
rowKey: `${sectionKey}:${workspace.id}`,
sectionKey,
worktree: workspace,
repo,
depth: 0,
groupDepth: 0,
lineageTrail: [],
isLastLineageChild: false,
lineageChildCount: 0
}
}
const projectGroup: ProjectGroup = {
id: 'group-1',
name: 'Group 1',
parentPath: '/group-1',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const folderWorkspace: FolderWorkspace = {
id: 'folder-1',
projectGroupId: projectGroup.id,
name: 'Folder 1',
folderPath: '/group-1/folder-1',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
describe('getRenderedWorktreesInSidebarOrder', () => {
it('keeps folder workspaces in visual order while preferring natural pinned rows', () => {
const pinned = worktree('pinned', true)
const afterFolder = worktree('after-folder')
const rows: HostSectionRow[] = [
item(pinned, 'pinned'),
{
type: 'folder-workspace',
key: 'folder-workspace:folder-1',
folderWorkspace,
projectGroup,
depth: 0,
groupDepth: 0
},
item(pinned, 'repo:repo-1'),
item(afterFolder, 'repo:repo-1')
]
expect(
getRenderedWorktreesInSidebarOrder(rows, 'duplicate-in-groups').map(({ id }) => id)
).toEqual(['folder:folder-1', 'pinned', 'after-folder'])
})
})
@@ -0,0 +1,64 @@
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
import type { Worktree } from '../../../../shared/types'
import type { HostSectionRow } from './host-section-rows'
import {
PINNED_GROUP_KEY,
type PinnedWorktreeDisplayPolicy,
type WorktreeRow
} from './worktree-list-groups'
export function getPreferredWorktreeRows(
rows: readonly WorktreeRow[],
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
): WorktreeRow[] {
// Why: duplicated pinned rows represent one workspace to navigation and selection.
// Prefer its natural-group copy, falling back to Pinned when that copy is hidden.
if (pinnedDisplayPolicy === 'single-location') {
const seen = new Set<string>()
return rows.filter((row) => {
if (seen.has(row.worktree.id)) {
return false
}
seen.add(row.worktree.id)
return true
})
}
const preferredRows: WorktreeRow[] = []
const seen = new Set<string>()
for (const row of rows) {
if (row.sectionKey === PINNED_GROUP_KEY || seen.has(row.worktree.id)) {
continue
}
preferredRows.push(row)
seen.add(row.worktree.id)
}
for (const row of rows) {
if (seen.has(row.worktree.id)) {
continue
}
preferredRows.push(row)
seen.add(row.worktree.id)
}
return preferredRows
}
export function getRenderedWorktreesInSidebarOrder(
rows: readonly HostSectionRow[],
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
): Worktree[] {
const itemRows = rows.filter((row): row is WorktreeRow => row.type === 'item')
const preferredRowKeys = new Set(
getPreferredWorktreeRows(itemRows, pinnedDisplayPolicy).map((row) => row.rowKey)
)
const renderedWorktrees: Worktree[] = []
for (const row of rows) {
if (row.type === 'item' && preferredRowKeys.has(row.rowKey)) {
renderedWorktrees.push(row.worktree)
} else if (row.type === 'folder-workspace') {
renderedWorktrees.push(folderWorkspaceToWorktree(row.folderWorkspace))
}
}
return renderedWorktrees
}