mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
Fix pinned sidebar order and render churn
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user