diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx
index 8a392e30515..d326b541a71 100644
--- a/src/renderer/src/components/settings/AppearancePane.tsx
+++ b/src/renderer/src/components/settings/AppearancePane.tsx
@@ -52,7 +52,10 @@ import {
import { translate } from '@/i18n/i18n'
import type { UiLanguage } from '../../../../shared/ui-language'
import { LeftSidebarAppearanceSetting } from './LeftSidebarAppearanceSetting'
-import { getWorkspaceCardLayoutEntry } from './appearance-sidebar-search'
+import {
+ getShowPinnedWorktreesInGroupsEntry,
+ getWorkspaceCardLayoutEntry
+} from './appearance-sidebar-search'
export { getAppearancePaneSearchEntries }
type AppearancePaneProps = {
@@ -117,6 +120,7 @@ export function AppearancePane({
const systemTrayEntries = getSystemTrayEntries({ showSystemTray: isDesktopWindows })
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry()
+ const showPinnedWorktreesInGroupsEntry = getShowPinnedWorktreesInGroupsEntry()
const visibleSections = [
matchesSettingsSearch(searchQuery, getThemeEntries()) ||
(SHOW_UI_LANGUAGE_SETTING && matchesSettingsSearch(searchQuery, getLanguageEntries())) ||
@@ -569,6 +573,29 @@ export function AppearancePane({
}
/>
+
+
+
+ updateSettings({
+ showPinnedWorktreesInGroups: !(settings.showPinnedWorktreesInGroups === true)
+ })
+ }
+ />
+
) : null,
diff --git a/src/renderer/src/components/settings/appearance-sidebar-search.ts b/src/renderer/src/components/settings/appearance-sidebar-search.ts
index d723e3c4a0e..9b31ffb015d 100644
--- a/src/renderer/src/components/settings/appearance-sidebar-search.ts
+++ b/src/renderer/src/components/settings/appearance-sidebar-search.ts
@@ -79,6 +79,54 @@ export const getWorkspaceCardLayoutEntry = createLocalizedCatalog(
})
)
+export const getShowPinnedWorktreesInGroupsEntry = createLocalizedCatalog(
+ (): SettingsSearchEntry => ({
+ title: translate(
+ 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.title',
+ 'Show pinned worktrees in other groups'
+ ),
+ description: translate(
+ 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.description',
+ 'Pinned worktrees also appear in All, Project, Status, and PR groups.'
+ ),
+ keywords: [
+ ...translateSearchKeyword(
+ 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.pinned',
+ 'pinned'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.worktree',
+ 'worktree'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.workspace',
+ 'workspace'
+ ),
+ ...translateSearchKeyword('auto.components.settings.appearance.search.5bff6a2ef0', 'sidebar'),
+ ...translateSearchKeyword(
+ 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.all',
+ 'all'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.project',
+ 'project'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.status',
+ 'status'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.pr',
+ 'pr'
+ ),
+ ...translateSearchKeyword(
+ 'auto.components.settings.appearance.search.showPinnedWorktreesInGroups.duplicate',
+ 'duplicate'
+ )
+ ]
+ })
+)
+
export const getSidebarEntries = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate('auto.components.settings.appearance.search.155a1e7438', 'Show Tasks Button'),
@@ -144,5 +192,6 @@ export const getSidebarEntries = createLocalizedCatalog((): SettingsSearchEntry[
]
},
getWorkspaceCardLayoutEntry(),
- getLeftSidebarAppearanceEntry()
+ getLeftSidebarAppearanceEntry(),
+ getShowPinnedWorktreesInGroupsEntry()
])
diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts
index 56ca5e3c283..29c37101138 100644
--- a/src/renderer/src/components/settings/terminal-search.test.ts
+++ b/src/renderer/src/components/settings/terminal-search.test.ts
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import { getTerminalPaneSearchEntries } from './terminal-search'
import { getAppearancePaneSearchEntries, getSidebarEntries } from './appearance-search'
-import { getWorkspaceCardLayoutEntry } from './appearance-sidebar-search'
+import {
+ getShowPinnedWorktreesInGroupsEntry,
+ getWorkspaceCardLayoutEntry
+} from './appearance-sidebar-search'
import { matchesSettingsSearch } from './settings-search'
describe('getTerminalPaneSearchEntries', () => {
@@ -172,4 +175,12 @@ describe('getTerminalPaneSearchEntries', () => {
it('matches the Appearance catalog for compact workspace card searches', () => {
expect(matchesSettingsSearch('compact', getAppearancePaneSearchEntries())).toBe(true)
})
+
+ it('includes pinned worktree duplicate display in sidebar and Appearance search', () => {
+ const entry = getShowPinnedWorktreesInGroupsEntry()
+
+ expect(getSidebarEntries()).toContainEqual(entry)
+ expect(getAppearancePaneSearchEntries()).toContainEqual(entry)
+ expect(matchesSettingsSearch('duplicate', entry)).toBe(true)
+ })
})
diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
index 6ce0830d27d..dd4ea4de31e 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
+++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
@@ -10,6 +10,7 @@ import type {
WorktreeLineage
} from '../../../../shared/types'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
+import type * as WorktreeListModule from './WorktreeList'
const mockStore = vi.hoisted(() => ({
state: {} as Record
@@ -21,6 +22,7 @@ type WorktreeListComponent = React.ComponentType<{
}>
let WorktreeList: WorktreeListComponent
+let getPinnedWorktreeRevealCollapsedGroupKeys: typeof WorktreeListModule.getPinnedWorktreeRevealCollapsedGroupKeys
function makeFolderWorkspacePathStatusMockState(): Record {
return {
@@ -426,12 +428,12 @@ function setFolderWorkspaceFixtureState(
}
}
-function setPinnedDuplicateFixtureState(): void {
+function setPinnedFixtureState(): void {
const repo = makeRepo()
const pinned = makeWorktree({
id: 'pinned',
instanceId: 'pinned-instance',
- displayName: 'pinned duplicate',
+ displayName: 'pinned workspace',
branch: 'pinned-branch',
sortOrder: 20
})
@@ -842,8 +844,10 @@ function escapeRegExp(value: string): string {
describe('WorktreeList lineage child card renderer', () => {
beforeAll(async () => {
- WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent
- }, 20_000)
+ const module = await import('./WorktreeList')
+ WorktreeList = module.default as WorktreeListComponent
+ getPinnedWorktreeRevealCollapsedGroupKeys = module.getPinnedWorktreeRevealCollapsedGroupKeys
+ }, 60_000)
it('renders project group headers when repos import before worktree rows load', async () => {
setProjectGroupWithoutWorktreeRowsState()
@@ -877,6 +881,24 @@ describe('WorktreeList lineage child card renderer', () => {
expect(markup).not.toContain('data-repo-header-collapse-affordance=""')
})
+ it('uncollapses pinned reveal through the pinned section after host expansion', () => {
+ const worktree = makeWorktree({
+ id: 'pinned-ssh',
+ instanceId: 'pinned-ssh-instance',
+ displayName: 'Pinned SSH workspace',
+ branch: 'pinned-ssh',
+ sortOrder: 1
+ })
+ worktree.isPinned = true
+
+ expect(
+ getPinnedWorktreeRevealCollapsedGroupKeys({
+ worktree,
+ collapsedGroups: new Set(['host:ssh:builder-1', 'pinned', 'done'])
+ })
+ ).toEqual(['pinned'])
+ })
+
it('renders a collapse chevron on grouped repo headers with worktrees', async () => {
setLineageFixtureState('repo')
const markup = await renderWorktreeListMarkup()
@@ -1075,12 +1097,23 @@ describe('WorktreeList lineage child card renderer', () => {
).toBe(30)
})
- it('points aria-activedescendant at the natural row for active pinned duplicates', async () => {
- setPinnedDuplicateFixtureState()
+ it('points aria-activedescendant at the pinned row for active pinned workspaces', async () => {
+ setPinnedFixtureState()
+ const markup = await renderWorktreeListMarkup()
+
+ expect(markup).toContain('aria-activedescendant="worktree-list-option-pinned%3Apinned"')
+ expect(markup).toContain('id="worktree-list-option-pinned%3Apinned"')
+ expect(markup).not.toContain('id="worktree-list-option-all%3Apinned"')
+ })
+
+ it('points aria-activedescendant at the natural duplicate when enabled', async () => {
+ setPinnedFixtureState()
+ mockStore.state.settings = { showPinnedWorktreesInGroups: true }
const markup = await renderWorktreeListMarkup()
expect(markup).toContain('aria-activedescendant="worktree-list-option-all%3Apinned"')
expect(markup).toContain('id="worktree-list-option-pinned%3Apinned"')
+ expect(markup).toContain('id="worktree-list-option-all%3Apinned"')
})
it('opens inline rename only for the row-scoped lineage child request', async () => {
diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx
index 3764ee8adff..dc0e7ef886f 100644
--- a/src/renderer/src/components/sidebar/WorktreeList.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeList.tsx
@@ -33,6 +33,7 @@ import {
useWorktreeMap
} from '@/store/selectors'
import WorktreeCard, { type ActiveSurfaceVariant } from './WorktreeCard'
+import StatusIndicator from './StatusIndicator'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
import { PendingWorktreeRow } from './PendingWorktreeRow'
import { SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT } from './WorktreeCardAgents'
@@ -79,7 +80,9 @@ import {
PINNED_GROUP_KEY,
buildRows,
getGroupKeysForWorktree,
- getLineageGroupKey
+ getLineageGroupKey,
+ getPinnedWorktreeDisplayPolicy,
+ type PinnedWorktreeDisplayPolicy
} from './worktree-list-groups'
import {
estimateRenderRowSize,
@@ -111,8 +114,11 @@ import {
import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos'
import {
getVisibleWorktreeBrowserActivityTabs,
- getVisibleWorktreeTerminalActivityTabs
+ getVisibleWorktreeTerminalActivityTabs,
+ getWorktreeSectionTerminalActivityTabs
} from './visible-worktree-activity-inputs'
+import { selectTerminalLayoutRootsForWorktrees } from './worktree-card-status-inputs'
+import { buildWorktreeSectionActivitySummaries } from './worktree-section-activity'
import {
VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT,
useVirtualizedScrollAnchor,
@@ -413,9 +419,9 @@ function markSidebarWorktreeActiveImmediately(worktreeId: string, primaryRowKey?
? option.dataset.worktreeRowKey === primaryRowKey
? 'primary'
: 'secondary'
- : option.dataset.worktreeSectionKey === PINNED_GROUP_KEY
- ? 'secondary'
- : 'primary'
+ : option === nextOption
+ ? 'primary'
+ : 'secondary'
const surface = option.matches('[data-worktree-card-surface]')
? option
: option.querySelector('[data-worktree-card-surface]')
@@ -452,6 +458,7 @@ type VirtualizedWorktreeViewportProps = {
activeWorktreeId: string | null
currentWorktreeId: string | null
groupBy: WorktreeGroupBy
+ pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
projectOrderBy: ProjectOrderBy
toggleGroup: (key: string) => void
collapsedGroups: Set
@@ -483,6 +490,7 @@ type VirtualizedWorktreeViewportProps = {
worktree: Worktree
) => readonly Worktree[]
repoMap: Map
+ defaultHostId: ExecutionHostId
worktreeMap: Map
worktreeLineageById: Record
repoOrder: Map
@@ -563,6 +571,27 @@ function SectionMetricsBadge({ count }: { count: number }): React.JSX.Element {
)
}
+function SectionRunningBadge({ count }: { count: number }): React.JSX.Element {
+ const label = formatSectionActivityLabel(count, 'running workspace')
+
+ return (
+
+
+
+
+ {count}
+
+
+
+ {label}
+
+
+ )
+}
+
function HostHeaderHealthIcon({
health
}: {
@@ -878,6 +907,50 @@ export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | n
return row.type === 'item' && row.worktree.id === worktreeId
}
+function isPinnedWorktreeRow(row: WorktreeItemRow): boolean {
+ return row.sectionKey === PINNED_GROUP_KEY
+}
+
+function getRenderRowWorktreeItem(row: RenderRow, worktreeId: string): WorktreeItemRow | null {
+ if (row.type === 'lineage-group') {
+ return row.rows.find((item) => item.worktree.id === worktreeId) ?? null
+ }
+ return row.type === 'item' && row.worktree.id === worktreeId ? row : null
+}
+
+function getPreferredWorktreeRows(
+ rows: readonly WorktreeItemRow[],
+ pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
+): WorktreeItemRow[] {
+ if (pinnedDisplayPolicy === 'single-location') {
+ const seen = new Set()
+ 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()
+ 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
@@ -898,20 +971,10 @@ function getRenderRowOptionId(
return undefined
}
-function renderRowContainsNaturalWorktree(row: RenderRow, worktreeId: string): boolean {
- if (row.type === 'lineage-group') {
- return row.rows.some(
- (item) => item.worktree.id === worktreeId && item.sectionKey !== PINNED_GROUP_KEY
- )
- }
- return (
- row.type === 'item' && row.worktree.id === worktreeId && row.sectionKey !== PINNED_GROUP_KEY
- )
-}
-
function getActiveDescendantOptionId(args: {
activeWorktreeId: string | null
primaryActiveRowKey?: string
+ pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
renderRows: readonly RenderRow[]
virtualItems: readonly { index: number }[]
}): string | undefined {
@@ -927,19 +990,67 @@ function getActiveDescendantOptionId(args: {
}
}
}
- for (const item of args.virtualItems) {
- const row = args.renderRows[item.index]
- if (row && renderRowContainsNaturalWorktree(row, args.activeWorktreeId)) {
- return getRenderRowOptionId(row, args.activeWorktreeId)
- }
- }
+ let fallbackOptionId: string | undefined
for (const item of args.virtualItems) {
const row = args.renderRows[item.index]
if (row && renderRowContainsWorktree(row, args.activeWorktreeId)) {
- return getRenderRowOptionId(row, args.activeWorktreeId)
+ const optionId = getRenderRowOptionId(row, args.activeWorktreeId)
+ if (!optionId) {
+ continue
+ }
+ const itemRow = getRenderRowWorktreeItem(row, args.activeWorktreeId)
+ if (
+ args.pinnedDisplayPolicy === 'duplicate-in-groups' &&
+ itemRow &&
+ !isPinnedWorktreeRow(itemRow)
+ ) {
+ return optionId
+ }
+ fallbackOptionId ??= optionId
}
}
- return undefined
+ return fallbackOptionId
+}
+
+function findPreferredRenderRowIndexForWorktree(
+ renderRows: readonly RenderRow[],
+ worktreeId: string,
+ pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
+): number {
+ let fallbackIndex = -1
+ for (let index = 0; index < renderRows.length; index++) {
+ const row = renderRows[index]
+ if (!renderRowContainsWorktree(row, worktreeId)) {
+ continue
+ }
+ if (fallbackIndex === -1) {
+ fallbackIndex = index
+ }
+ const itemRow = getRenderRowWorktreeItem(row, worktreeId)
+ if (pinnedDisplayPolicy === 'duplicate-in-groups' && itemRow && !isPinnedWorktreeRow(itemRow)) {
+ return index
+ }
+ }
+ return fallbackIndex
+}
+
+export function getPinnedWorktreeRevealCollapsedGroupKeys({
+ worktree,
+ collapsedGroups
+}: {
+ worktree: Worktree
+ collapsedGroups: ReadonlySet
+}): string[] {
+ if (!worktree.isPinned) {
+ return []
+ }
+ const keys: string[] = []
+ // Why: the reveal effect opens the owning host first; returning it here would
+ // toggle the same host closed again while React state is still stale.
+ if (collapsedGroups.has(PINNED_GROUP_KEY)) {
+ keys.push(PINNED_GROUP_KEY)
+ }
+ return keys
}
function uniqueWorktreeIds(ids: readonly string[]): string[] {
@@ -1006,6 +1117,11 @@ export function getRenderRowKey(row: RenderRow): string {
export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] {
const groups: WorktreeDragGroup[] = []
let current: { key: string; ids: string[] } | null = null
+ const naturalWorktreeIds = new Set(
+ rows.flatMap((row) =>
+ row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : []
+ )
+ )
for (const row of rows) {
if (row.type === 'header') {
@@ -1021,7 +1137,7 @@ export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup
) {
continue
}
- if (row.sectionKey === PINNED_GROUP_KEY) {
+ if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) {
continue
}
if (!current) {
@@ -1041,19 +1157,27 @@ export function canKeepImportedWorktreesHidden(
return row.placement === 'repo-group' && actionState?.forceVisible !== true
}
-function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): {
+export function getWorktreeDragIndexes(rows: readonly HostSectionRow[]): {
groupKeyByRowKey: Map
groupIndexByRowKey: Map
} {
const groupKeyByRowKey = new Map()
const groupIndexByRowKey = new Map()
const groupIndexes = new Map()
+ const naturalWorktreeIds = new Set(
+ rows.flatMap((row) =>
+ row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : []
+ )
+ )
for (const row of rows) {
if (row.type === 'header') {
groupIndexes.set(row.key, 0)
continue
}
- if (row.type !== 'item' || row.sectionKey === PINNED_GROUP_KEY) {
+ if (row.type !== 'item') {
+ continue
+ }
+ if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) {
continue
}
const index = groupIndexes.get(row.sectionKey) ?? 0
@@ -1078,6 +1202,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
activeWorktreeId,
currentWorktreeId,
groupBy,
+ pinnedDisplayPolicy,
projectOrderBy,
toggleGroup,
collapsedGroups,
@@ -1106,6 +1231,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
onImmediateWorktreeActivate,
onContextMenuSelect,
repoMap,
+ defaultHostId,
worktreeMap,
worktreeLineageById,
repoOrder,
@@ -1275,13 +1401,25 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
useEffect(() => () => onHostDragActiveChange(false), [onHostDragActiveChange])
const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows])
const worktreeDragUnitGroups = useMemo(() => getWorktreeDragUnitGroups(rows), [rows])
+ const naturalDragWorktreeIds = useMemo(
+ () =>
+ new Set(
+ rows.flatMap((row) =>
+ row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : []
+ )
+ ),
+ [rows]
+ )
const worktreeLineageDragRows = useMemo(
() =>
rows
.filter((row): row is WorktreeItemRow => row.type === 'item')
- .filter((row) => row.sectionKey !== PINNED_GROUP_KEY)
+ .filter(
+ (row) =>
+ row.sectionKey !== PINNED_GROUP_KEY || !naturalDragWorktreeIds.has(row.worktree.id)
+ )
.map((row) => ({ worktreeId: row.worktree.id, depth: row.depth })),
- [rows]
+ [naturalDragWorktreeIds, rows]
)
const getReorderDraggedIds = useCallback(
(draggedIds: readonly string[]) =>
@@ -1447,28 +1585,21 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
return rowStillVisible ? current : null
})
}, [activeWorktreeId, rows])
- const activeWorktreeHasNaturalRow = useMemo(
- () =>
- activeWorktreeId !== null &&
- rows.some(
- (row) =>
- row.type === 'item' &&
- row.worktree.id === activeWorktreeId &&
- row.sectionKey !== PINNED_GROUP_KEY
- ),
- [activeWorktreeId, rows]
- )
const getActiveSurfaceVariant = useCallback(
(row: WorktreeItemRow): ActiveSurfaceVariant => {
if (primaryActiveWorktreeRow?.worktreeId === row.worktree.id) {
return primaryActiveWorktreeRow.rowKey === row.rowKey ? 'primary' : 'secondary'
}
- if (activeWorktreeHasNaturalRow && row.sectionKey === PINNED_GROUP_KEY) {
+ if (
+ pinnedDisplayPolicy === 'duplicate-in-groups' &&
+ activeWorktreeId === row.worktree.id &&
+ isPinnedWorktreeRow(row)
+ ) {
return 'secondary'
}
return 'primary'
},
- [activeWorktreeHasNaturalRow, primaryActiveWorktreeRow]
+ [activeWorktreeId, pinnedDisplayPolicy, primaryActiveWorktreeRow]
)
const handleImmediateWorktreeRowActivate = useCallback(
(worktreeId: string, rowKey: string | undefined): void => {
@@ -1768,7 +1899,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
} else {
const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktree.worktreeId)
- if (targetWorktree && !targetWorktree.isPinned) {
+ const targetRepo = targetWorktree ? repoMap.get(targetWorktree.repoId) : undefined
+ if (targetWorktree) {
+ const hostId =
+ targetRepo && (targetRepo.connectionId || targetRepo.executionHostId)
+ ? getRepoExecutionHostId(targetRepo)
+ : defaultHostId
+ const hostGroupKey = `host:${hostId}`
+ if (collapsedGroups.has(hostGroupKey)) {
+ toggleGroup(hostGroupKey)
+ }
+
const seen = new Set()
let current: Worktree | undefined = targetWorktree
while (current && !seen.has(current.id)) {
@@ -1789,26 +1930,23 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
current = parent
}
- }
- if (targetWorktree?.isPinned) {
- // Why: pinned worktrees live in the dedicated "Pinned" section regardless
- // of their PR-status / project group. Only uncollapse the Pinned header
- // itself — expanding the underlying status group would be surprising since
- // the user intentionally collapsed it.
- if (collapsedGroups.has(PINNED_GROUP_KEY)) {
- toggleGroup(PINNED_GROUP_KEY)
- }
- } else if (targetWorktree) {
- const groupKeys = getGroupKeysForWorktree(
- groupBy,
- targetWorktree,
- repoMap,
- prCache,
- workspaceStatuses,
- settings,
- projectGroups
- )
+ const groupKeys =
+ targetWorktree.isPinned && pinnedDisplayPolicy === 'single-location'
+ ? getPinnedWorktreeRevealCollapsedGroupKeys({
+ worktree: targetWorktree,
+ collapsedGroups
+ })
+ : getGroupKeysForWorktree(
+ groupBy,
+ targetWorktree,
+ repoMap,
+ prCache,
+ workspaceStatuses,
+ settings,
+ projectGroups,
+ projectGrouping
+ )
for (const groupKey of groupKeys) {
if (collapsedGroups.has(groupKey)) {
toggleGroup(groupKey)
@@ -1828,8 +1966,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
worktrees,
folderWorkspaces
)
- const targetIndex = renderRows.findIndex((row) =>
- renderRowContainsWorktree(row, pendingRevealWorktree.worktreeId)
+ const targetIndex = findPreferredRenderRowIndexForWorktree(
+ renderRows,
+ pendingRevealWorktree.worktreeId,
+ pinnedDisplayPolicy
)
const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists })
if (outcome === 'scroll-and-clear') {
@@ -1925,8 +2065,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
clearPendingRevealWorktreeId,
toggleGroup,
collapsedGroups,
+ defaultHostId,
workspaceStatuses,
settings,
+ pinnedDisplayPolicy,
+ projectGrouping,
projectGroups,
pendingRevealRetryTick,
flashRevealedWorktree,
@@ -2034,16 +2177,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
new Set(),
new Map(),
[],
- projectGrouping
+ projectGrouping,
+ [],
+ undefined,
+ defaultHostId,
+ pinnedDisplayPolicy
).filter((r): r is Extract => r.type === 'item')
- const seenWorktreeIds = new Set()
- const worktreeRows = allWorktreeRows.filter((row) => {
- if (seenWorktreeIds.has(row.worktree.id)) {
- return false
- }
- seenWorktreeIds.add(row.worktree.id)
- return true
- })
+ const worktreeRows = getPreferredWorktreeRows(allWorktreeRows, pinnedDisplayPolicy)
if (worktreeRows.length === 0) {
return
}
@@ -2070,7 +2210,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
// it must flow through the same activation helper that records history.
activateAndRevealWorktree(nextWorktreeId)
- const rowIndex = renderRows.findIndex((row) => renderRowContainsWorktree(row, nextWorktreeId))
+ const rowIndex = findPreferredRenderRowIndexForWorktree(
+ renderRows,
+ nextWorktreeId,
+ pinnedDisplayPolicy
+ )
if (rowIndex !== -1) {
virtualizer.scrollToIndex(rowIndex, { align: 'auto' })
}
@@ -2083,6 +2227,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
projectOrderBy,
worktrees,
repoMap,
+ defaultHostId,
prCache,
repoOrder,
workspaceStatuses,
@@ -2090,7 +2235,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
worktreeMap,
settings,
projectGroups,
- projectGrouping
+ projectGrouping,
+ pinnedDisplayPolicy
]
)
@@ -3291,6 +3437,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
primaryActiveWorktreeRow?.worktreeId === activeWorktreeId
? primaryActiveWorktreeRow.rowKey
: undefined,
+ pinnedDisplayPolicy,
renderRows,
virtualItems
})
@@ -3819,6 +3966,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
{row.label}
+ {isHeaderCollapsed && row.runningCount && row.runningCount > 0 ? (
+
+ ) : null}
@@ -4338,8 +4488,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
onCardDragStart={handleWorktreeCardDragStart}
onCardDragEnd={clearWorktreeDrag}
hideRepoBadge={groupBy === 'repo'}
- // Why: pinned worktrees also render in their natural group;
- // only the overlay row is the mixed-repo section needing icons.
+ // Why: pinned worktrees mix repos in one section; only that
+ // section needs the leading repo identity chip.
hostContextLabel={itemRow.hostContextLabel}
inPinnedSection={isPinnedOverlayRow}
renameRowKey={itemRow.rowKey}
@@ -4743,6 +4893,21 @@ const WorktreeList = React.memo(function WorktreeList({
: null
)
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)
@@ -5232,6 +5397,49 @@ 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(
@@ -5255,7 +5463,10 @@ const WorktreeList = React.memo(function WorktreeList({
pendingCreations,
projectGrouping,
visibleFolderWorkspacesForRows,
- hostLabelById
+ hostLabelById,
+ defaultHostId,
+ pinnedDisplayPolicy,
+ sectionActivitySummaries
),
[
groupBy,
@@ -5263,6 +5474,7 @@ const WorktreeList = React.memo(function WorktreeList({
repoMap,
prCache,
effectiveCollapsedGroups,
+ defaultHostId,
repoOrder,
workspaceStatuses,
projectOrderBy,
@@ -5275,7 +5487,9 @@ const WorktreeList = React.memo(function WorktreeList({
placeholderRepoIds,
importedWorktreesByRepo,
pendingCreations,
- hostLabelById
+ hostLabelById,
+ pinnedDisplayPolicy,
+ sectionActivitySummaries
]
)
const orderedHostOptions = useMemo(
@@ -5338,19 +5552,23 @@ 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(
- () =>
- sectionRows.flatMap((row) => {
- if (row.type === 'item') {
- return [row.worktree]
- }
- if (row.type === 'folder-workspace') {
- return [folderWorkspaceToWorktree(row.folderWorkspace)]
- }
- return []
- }),
- [sectionRows]
- )
+ 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 renderedWorktreeIds = useMemo(
() => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)),
[renderedWorktrees]
@@ -6054,6 +6272,7 @@ const WorktreeList = React.memo(function WorktreeList({
activeWorktreeId={selectedSidebarWorktreeId}
currentWorktreeId={currentSidebarWorktreeId}
groupBy={groupBy}
+ pinnedDisplayPolicy={pinnedDisplayPolicy}
projectOrderBy={projectOrderBy}
toggleGroup={toggleGroup}
collapsedGroups={effectiveCollapsedGroups}
@@ -6082,6 +6301,7 @@ const WorktreeList = React.memo(function WorktreeList({
onImmediateWorktreeActivate={handleImmediateWorktreeActivate}
onContextMenuSelect={selectForContextMenu}
repoMap={repoMap}
+ defaultHostId={defaultHostId}
worktreeMap={worktreeMap}
worktreeLineageById={worktreeLineageById}
repoOrder={repoOrder}
diff --git a/src/renderer/src/components/sidebar/host-section-rows.test.ts b/src/renderer/src/components/sidebar/host-section-rows.test.ts
index e03ddbb63cc..136b835eb33 100644
--- a/src/renderer/src/components/sidebar/host-section-rows.test.ts
+++ b/src/renderer/src/components/sidebar/host-section-rows.test.ts
@@ -46,6 +46,19 @@ function header(key: string, label = key): Extract {
}
}
+function pinnedHeader(
+ counts: ReadonlyMap<'local' | 'ssh:ssh-1', number>,
+ idsByHost?: ReadonlyMap<'local' | 'ssh:ssh-1', readonly string[]>
+): Extract {
+ return {
+ ...header(PINNED_GROUP_KEY, 'Pinned'),
+ count: Array.from(counts.values()).reduce((sum, count) => sum + count, 0),
+ hostWorktreeCounts: counts,
+ hostWorktreeIds: idsByHost,
+ worktreeIds: idsByHost ? Array.from(idsByHost.values()).flat() : undefined
+ }
+}
+
function repoHeader(project: Repo): Extract {
return {
...header(`repo:${project.id}`, project.displayName),
@@ -294,17 +307,20 @@ describe('addHostSectionRows', () => {
])
})
- it('copies global pinned and all headers into each mixed-host section', () => {
+ it('copies global pinned and all headers into each mixed-host section without duplicating pins', () => {
const local = repo('local')
const ssh = repo('ssh', 'ssh-1')
const rows = [
- header(PINNED_GROUP_KEY, 'Pinned'),
+ pinnedHeader(
+ new Map([
+ ['local', 1],
+ ['ssh:ssh-1', 1]
+ ])
+ ),
pinnedItem('local-pinned', local, PINNED_GROUP_KEY),
pinnedItem('ssh-pinned', ssh, PINNED_GROUP_KEY),
header('all', 'All'),
- pinnedItem('local-pinned', local, 'all'),
item('local-normal', local),
- pinnedItem('ssh-pinned', ssh, 'all'),
item('ssh-normal', ssh)
]
@@ -329,19 +345,143 @@ describe('addHostSectionRows', () => {
'pinned',
'pinned:local-pinned',
'all',
- 'all:local-pinned',
'repo:local:local-normal',
'host:ssh:ssh-1',
'pinned',
'pinned:ssh-pinned',
'all',
- 'all:ssh-pinned',
'repo:ssh:ssh-normal'
])
expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([
{ label: 'Local Mac', count: 2 },
{ label: 'Builder', count: 2 }
])
+ expect(
+ sectioned.filter((row) => row.type === 'header' && row.key === PINNED_GROUP_KEY)
+ ).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ count: 1, hostId: 'local' }),
+ expect.objectContaining({ count: 1, hostId: 'ssh:ssh-1' })
+ ])
+ )
+ })
+
+ it('keeps collapsed pinned rows attributable to their owning hosts', () => {
+ const rows = [
+ pinnedHeader(
+ new Map([
+ ['local', 1],
+ ['ssh:ssh-1', 2]
+ ])
+ )
+ ]
+
+ const sectioned = addHostSectionRows({
+ rows,
+ hostOptions: [
+ {
+ id: 'local',
+ kind: 'local',
+ label: 'Local Mac',
+ detail: 'This computer',
+ health: 'local'
+ },
+ { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' }
+ ],
+ workspaceHostScope: 'all',
+ defaultHostId: 'local',
+ collapsedHostKeys: new Set(['host:ssh:ssh-1'])
+ })
+
+ expect(sectioned.map(rowKey)).toEqual(['host:local', 'pinned', 'host:ssh:ssh-1'])
+ expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([
+ { hostId: 'local', collapsed: false, count: 1 },
+ { hostId: 'ssh:ssh-1', collapsed: true, count: 2 }
+ ])
+ expect(sectioned.filter((row) => row.type === 'header')).toMatchObject([
+ { key: PINNED_GROUP_KEY, count: 1, hostId: 'local' }
+ ])
+ })
+
+ it('does not double-count a collapsed pinned header and its natural duplicate row', () => {
+ const local = repo('local')
+ const ssh = repo('ssh', 'ssh-1')
+ const rows = [
+ pinnedHeader(new Map([['local', 1]]), new Map([['local', ['wt-1']]])),
+ { ...header('all', 'All'), count: 1, worktreeIds: ['wt-1'] },
+ item('wt-1', local),
+ repoHeader(ssh),
+ item('ssh-wt', ssh)
+ ]
+
+ const sectioned = addHostSectionRows({
+ rows,
+ hostOptions: [
+ {
+ id: 'local',
+ kind: 'local',
+ label: 'Local Mac',
+ detail: 'This computer',
+ health: 'local'
+ },
+ { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' }
+ ],
+ workspaceHostScope: 'all',
+ defaultHostId: 'local'
+ })
+
+ expect(
+ sectioned.find((row) => row.type === 'host-header' && row.hostId === 'local')
+ ).toMatchObject({ count: 1 })
+ })
+
+ it('localizes collapsed natural headers even when the hidden rows are owned by one host', () => {
+ const ssh = repo('ssh', 'ssh-1')
+ const localHostId = 'local' as const
+ const rows = [
+ {
+ ...header('all', 'All'),
+ count: 1,
+ hostWorktreeCounts: new Map([[localHostId, 1]]),
+ hostWorktreeIds: new Map([[localHostId, ['local-wt']]]),
+ worktreeIds: ['local-wt'],
+ runningCount: 1,
+ hostRunningCounts: new Map([[localHostId, 1]])
+ },
+ repoHeader(ssh),
+ item('ssh-wt', ssh)
+ ]
+
+ const sectioned = addHostSectionRows({
+ rows,
+ hostOptions: [
+ {
+ id: 'local',
+ kind: 'local',
+ label: 'Local Mac',
+ detail: 'This computer',
+ health: 'local'
+ },
+ { id: 'ssh:ssh-1', kind: 'ssh', label: 'Builder', detail: 'SSH', health: 'available' }
+ ],
+ workspaceHostScope: 'all',
+ defaultHostId: 'local'
+ })
+
+ expect(sectioned.map(rowKey)).toEqual([
+ 'host:local',
+ 'all',
+ 'host:ssh:ssh-1',
+ 'repo:ssh',
+ 'ssh-wt'
+ ])
+ expect(sectioned.filter((row) => row.type === 'host-header')).toMatchObject([
+ { hostId: 'local', count: 1 },
+ { 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'] }
+ ])
})
it('groups explicitly runtime-owned repos under their owner host, not the focused host', () => {
diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts
index bfa18c3aaa9..ff58da06023 100644
--- a/src/renderer/src/components/sidebar/host-section-rows.ts
+++ b/src/renderer/src/components/sidebar/host-section-rows.ts
@@ -11,7 +11,6 @@ import type { ExecutionHostHealth } from '../../../../shared/execution-host-regi
import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types'
-import { PINNED_GROUP_KEY } from './worktree-list-groups'
import type { Row } from './worktree-list-groups'
export type HostHeaderRow = {
@@ -98,19 +97,28 @@ function countWorktreeRows(rows: readonly Row[]): number {
// while a visibly populated project sits right under it.
let count = 0
const seenWorktreeIds = new Set()
- let pendingHeaderCount: number | null = null
+ let pendingHeader: Extract | null = null
let pendingHeaderHadItems = false
const flushHeader = (): void => {
- if (pendingHeaderCount !== null && !pendingHeaderHadItems) {
- count += pendingHeaderCount
+ if (pendingHeader && !pendingHeaderHadItems) {
+ if (pendingHeader.worktreeIds) {
+ for (const worktreeId of pendingHeader.worktreeIds) {
+ if (!seenWorktreeIds.has(worktreeId)) {
+ count += 1
+ seenWorktreeIds.add(worktreeId)
+ }
+ }
+ } else {
+ count += pendingHeader.count
+ }
}
- pendingHeaderCount = null
+ pendingHeader = null
pendingHeaderHadItems = false
}
for (const row of rows) {
if (row.type === 'header') {
flushHeader()
- pendingHeaderCount = row.key === PINNED_GROUP_KEY ? null : row.count
+ pendingHeader = row
continue
}
if (row.type === 'item') {
@@ -118,13 +126,47 @@ function countWorktreeRows(rows: readonly Row[]): number {
count += 1
seenWorktreeIds.add(row.worktree.id)
}
- pendingHeaderHadItems = pendingHeaderCount !== null
+ pendingHeaderHadItems = pendingHeader !== null
}
}
flushHeader()
return count
}
+function localizePendingRowsForHost(
+ rows: readonly Extract[],
+ hostId: ExecutionHostId
+): Extract[] {
+ const localized: Extract[] = []
+ for (const row of rows) {
+ if (!row.hostWorktreeCounts) {
+ localized.push(row)
+ continue
+ }
+ 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
+ })
+ }
+ }
+ return localized
+}
+
+function getPendingRowsKey(rows: readonly Extract[]): string {
+ return rows
+ .map(
+ (pendingRow) =>
+ `${pendingRow.key}:${pendingRow.count}:${pendingRow.worktreeIds?.join(',') ?? ''}`
+ )
+ .join('\0')
+}
+
export function addHostSectionRows(args: {
rows: readonly Row[]
hostOptions: readonly HostSectionOption[]
@@ -160,18 +202,50 @@ export function addHostSectionRows(args: {
let pendingRows: Extract[] = []
let pendingRowsWereUsed = false
const pendingRowsKeyByHostId = new Map()
+ const flushUnusedPendingRows = (): void => {
+ if (pendingRows.length === 0 || pendingRowsWereUsed) {
+ return
+ }
+ const hostScopedRow = pendingRows.some((row) => row.hostWorktreeCounts)
+ if (!hostScopedRow) {
+ globalRows.push(...pendingRows)
+ return
+ }
+ for (const row of pendingRows) {
+ for (const [hostId, count] of row.hostWorktreeCounts ?? []) {
+ if (count <= 0) {
+ continue
+ }
+ 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
+ })
+ rowsByHostId.set(hostId, hostRows)
+ }
+ }
+ }
for (const row of args.rows) {
const rowHostId = getRowHostId(row, args.defaultHostId)
if (rowHostId) {
const hostRows = rowsByHostId.get(rowHostId) ?? []
if (pendingRows.length > 0) {
- const pendingRowsKey = pendingRows.map((pendingRow) => pendingRow.key).join('\0')
- if (pendingRowsKeyByHostId.get(rowHostId) !== pendingRowsKey) {
- hostRows.push(...pendingRows)
+ const localizedPendingRows = localizePendingRowsForHost(pendingRows, rowHostId)
+ const pendingRowsKey = getPendingRowsKey(localizedPendingRows)
+ if (
+ localizedPendingRows.length > 0 &&
+ pendingRowsKeyByHostId.get(rowHostId) !== pendingRowsKey
+ ) {
+ hostRows.push(...localizedPendingRows)
pendingRowsKeyByHostId.set(rowHostId, pendingRowsKey)
}
- pendingRowsWereUsed = true
+ pendingRowsWereUsed = pendingRowsWereUsed || localizedPendingRows.length > 0
}
hostRows.push(row)
rowsByHostId.set(rowHostId, hostRows)
@@ -180,6 +254,7 @@ export function addHostSectionRows(args: {
// Why: status/"All" headers describe the rows that follow. Buffer them
// for every host-owned run so host remains above the existing grouping.
if (row.type === 'header') {
+ flushUnusedPendingRows()
pendingRows = [row]
pendingRowsWereUsed = false
} else {
@@ -187,9 +262,7 @@ export function addHostSectionRows(args: {
}
}
- if (pendingRows.length > 0 && !pendingRowsWereUsed) {
- globalRows.push(...pendingRows)
- }
+ flushUnusedPendingRows()
const hostOrder: ExecutionHostId[] = []
for (const host of args.hostOptions) {
diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts
index c349dd6eba0..0279a9d63ed 100644
--- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts
+++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts
@@ -140,51 +140,32 @@ describe('runSleepWorktree', () => {
expect(requestAnimationFrame).toHaveBeenCalledTimes(1)
})
- it('anchors sleep restoration to the primary duplicate row', async () => {
- let frameCount = 0
- const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
- frameCount += 1
- if (frameCount === 1) {
- callback(0)
- }
- return frameCount
- })
+ it('anchors sleep restoration to the natural duplicate row when no primary row is marked', async () => {
+ const requestAnimationFrame = vi.fn(() => 1)
const scroller = {
dispatchEvent: vi.fn(),
scrollHeight: 100,
scrollTop: 0
}
+ const pinnedGetBoundingClientRect = vi.fn(() => ({ top: 10 }))
+ const naturalGetBoundingClientRect = vi.fn(() => ({ top: 42 }))
const pinnedRow = {
- closest: (selector: string) =>
- selector === '[data-worktree-virtual-row]' ? pinnedRow : null,
- getBoundingClientRect: () => ({ top: 10 })
+ getBoundingClientRect: pinnedGetBoundingClientRect
}
- let naturalTop = 40
const naturalRow = {
- closest: (selector: string) =>
- selector === '[data-worktree-virtual-row]' ? naturalRow : null,
- getBoundingClientRect: () => ({ top: naturalTop })
+ getBoundingClientRect: naturalGetBoundingClientRect
}
const pinnedOption = {
- dataset: {
- worktreeId: 'wt-1',
- worktreeRowKey: 'pinned:wt-1',
- worktreeSectionKey: 'pinned'
- },
+ dataset: { worktreeId: 'wt-1', worktreeRowKey: 'pinned:wt-1' },
closest: (selector: string) =>
selector === '[data-worktree-virtual-row]' ? pinnedRow : null,
querySelector: () => null
}
const naturalOption = {
- dataset: {
- worktreeId: 'wt-1',
- worktreeRowKey: 'all:wt-1',
- worktreeSectionKey: 'all'
- },
+ dataset: { worktreeId: 'wt-1', worktreeRowKey: 'all:wt-1' },
closest: (selector: string) =>
selector === '[data-worktree-virtual-row]' ? naturalRow : null,
- querySelector: (selector: string) =>
- selector === '[data-worktree-card-active="primary"]' ? {} : null
+ querySelector: () => null
}
vi.stubGlobal('document', {
querySelector: (selector: string) =>
@@ -195,13 +176,10 @@ describe('runSleepWorktree', () => {
vi.stubGlobal('window', { requestAnimationFrame })
mocks.state.activeWorktreeId = 'wt-1'
- mocks.state.setActiveWorktree.mockImplementation(() => {
- naturalTop = 45
- })
-
await runSleepWorktree('wt-1')
- expect(scroller.scrollTop).toBe(5)
+ expect(naturalGetBoundingClientRect).toHaveBeenCalled()
+ expect(pinnedGetBoundingClientRect).not.toHaveBeenCalled()
})
it('leaves activeWorktreeId alone when sleeping a background worktree', async () => {
diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts
index c0a34d6ad90..053fece73a2 100644
--- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts
+++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts
@@ -4,7 +4,6 @@ import { clearWorktreeSleepIntent, markWorktreeSleepIntent } from '@/lib/worktre
import { cancelPendingSidebarWorktreeActivation } from '@/lib/sidebar-worktree-activation'
import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor'
import { translate } from '@/i18n/i18n'
-import { PINNED_GROUP_KEY } from './worktree-list-groups'
/**
* Shared "sleep worktree" flow (close all panels to free memory / CPU)
@@ -29,13 +28,17 @@ function getSidebarWorktreeOptions(worktreeId: string): HTMLElement[] {
)
}
+function isPinnedSidebarWorktreeOption(element: HTMLElement): boolean {
+ return element.dataset.worktreeRowKey?.startsWith('pinned:') === true
+}
+
function findPrimarySidebarWorktreeOption(worktreeId: string): HTMLElement | null {
const options = getSidebarWorktreeOptions(worktreeId)
return (
options.find((element) =>
element.querySelector('[data-worktree-card-active="primary"]')
) ??
- options.find((element) => element.dataset.worktreeSectionKey !== PINNED_GROUP_KEY) ??
+ options.find((element) => !isPinnedSidebarWorktreeOption(element)) ??
options[0] ??
null
)
diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.test.ts b/src/renderer/src/components/sidebar/worktree-drag-units.test.ts
index f77ea72b2ee..9d6f230d5cd 100644
--- a/src/renderer/src/components/sidebar/worktree-drag-units.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-drag-units.test.ts
@@ -70,7 +70,26 @@ describe('getWorktreeDragUnitGroups', () => {
])
})
- it('ignores pinned overlay rows', () => {
+ it('includes pinned rows when they are the only rendered copy', () => {
+ const groups = getWorktreeDragUnitGroups([
+ header('pinned'),
+ item('pinned-copy', 0, 'pinned'),
+ item('other-pinned', 0, 'pinned')
+ ])
+
+ expect(groups).toEqual([
+ {
+ key: 'pinned',
+ worktreeIds: ['pinned-copy', 'other-pinned'],
+ units: [
+ { worktreeId: 'pinned-copy', worktreeIds: ['pinned-copy'] },
+ { worktreeId: 'other-pinned', worktreeIds: ['other-pinned'] }
+ ]
+ }
+ ])
+ })
+
+ it('uses natural drag units when pinned rows have duplicate natural copies', () => {
const groups = getWorktreeDragUnitGroups([
header('pinned'),
item('pinned-copy', 0, 'pinned'),
diff --git a/src/renderer/src/components/sidebar/worktree-drag-units.ts b/src/renderer/src/components/sidebar/worktree-drag-units.ts
index 4707423aae3..be0166e6142 100644
--- a/src/renderer/src/components/sidebar/worktree-drag-units.ts
+++ b/src/renderer/src/components/sidebar/worktree-drag-units.ts
@@ -18,6 +18,11 @@ export function getWorktreeDragUnitGroups(
): WorktreeDragUnitGroup[] {
const groups: WorktreeDragUnitGroup[] = []
let current: { key: string; units: WorktreeDragUnitGroup['units'] } | null = null
+ const naturalWorktreeIds = new Set(
+ rows.flatMap((row) =>
+ row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY ? [row.worktree.id] : []
+ )
+ )
for (const row of rows) {
if (row.type === 'header') {
@@ -37,7 +42,7 @@ export function getWorktreeDragUnitGroups(
) {
continue
}
- if (row.sectionKey === PINNED_GROUP_KEY) {
+ if (row.sectionKey === PINNED_GROUP_KEY && naturalWorktreeIds.has(row.worktree.id)) {
continue
}
if (!current) {
diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
index cb667f6f1d5..610716fc3c8 100644
--- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts
@@ -11,6 +11,7 @@ import {
getLineageGroupKey,
getLineageRenderInfo,
getPRGroupKey,
+ PINNED_GROUP_KEY,
type PendingCreationRef
} from './worktree-list-groups'
import {
@@ -215,7 +216,7 @@ describe('buildRows with pinned worktrees', () => {
const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set())
expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', label: 'Pinned' })
expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } })
- expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 3 })
+ expect(rows[2]).toMatchObject({ type: 'header', key: 'all', label: 'All', count: 2 })
expect(rows[2]).toMatchObject({ type: 'header', icon: ALL_GROUP_META.icon })
})
@@ -229,30 +230,55 @@ describe('buildRows with pinned worktrees', () => {
])
})
- it('keeps pinned worktrees above the All group', () => {
+ it('moves pinned worktrees out of the All group', () => {
const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set())
expect(rows).toMatchObject([
{ type: 'header', key: 'pinned' },
{ type: 'item', worktree: { id: 'wt-pinned' } },
- { type: 'header', key: 'all', count: 3 },
+ { type: 'header', key: 'all', count: 2 },
{ type: 'item', worktree: { id: 'wt-1' } },
- { type: 'item', worktree: { id: 'wt-pinned' } },
{ type: 'item', worktree: { id: 'wt-2' } }
])
})
+ it('duplicates pinned worktrees into All when the policy allows it', () => {
+ const rows = buildRows(
+ 'none',
+ [unpinned1, pinned, unpinned2],
+ repoMap,
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ undefined,
+ {},
+ undefined,
+ false,
+ { showPinnedWorktreesInGroups: true } as never
+ )
+
+ expect(rows).toMatchObject([
+ { type: 'header', key: 'pinned' },
+ { type: 'item', sectionKey: PINNED_GROUP_KEY, worktree: { id: 'wt-pinned' } },
+ { type: 'header', key: 'all', count: 3 },
+ { type: 'item', sectionKey: 'all', worktree: { id: 'wt-1' } },
+ { type: 'item', sectionKey: 'all', worktree: { id: 'wt-pinned' } },
+ { type: 'item', sectionKey: 'all', worktree: { id: 'wt-2' } }
+ ])
+ })
+
it('collapses the All group in groupBy none', () => {
const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set(['all']))
expect(rows).toMatchObject([
{ type: 'header', key: 'pinned' },
{ type: 'item', worktree: { id: 'wt-pinned' } },
- { type: 'header', key: 'all', count: 3 }
+ { type: 'header', key: 'all', count: 2 }
])
})
- it('emits status headers for all matching worktrees in groupBy workspace-status', () => {
+ it('emits status headers for unpinned matching worktrees in groupBy workspace-status', () => {
const rows = buildRows(
'workspace-status',
[unpinned1, pinned, unpinned2],
@@ -264,21 +290,45 @@ describe('buildRows with pinned worktrees', () => {
type: 'header',
key: 'workspace-status:in-progress',
label: 'In progress',
- count: 3
+ count: 2
})
expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } })
- expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } })
- expect(rows[5]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } })
+ expect(rows[4]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } })
})
- it('keeps pinned items in regular groups in pr-status mode', () => {
+ it('duplicates pinned worktrees into status groups when the policy allows it', () => {
+ const rows = buildRows(
+ 'workspace-status',
+ [unpinned1, pinned],
+ repoMap,
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ undefined,
+ {},
+ undefined,
+ false,
+ { showPinnedWorktreesInGroups: true } as never
+ )
+
+ expect(rows).toMatchObject([
+ { type: 'header', key: 'pinned', count: 1 },
+ { type: 'item', sectionKey: PINNED_GROUP_KEY, worktree: { id: 'wt-pinned' } },
+ { type: 'header', key: 'workspace-status:in-progress', count: 2 },
+ { type: 'item', sectionKey: 'workspace-status:in-progress', worktree: { id: 'wt-1' } },
+ { type: 'item', sectionKey: 'workspace-status:in-progress', worktree: { id: 'wt-pinned' } }
+ ])
+ })
+
+ it('moves pinned items out of regular groups in pr-status mode', () => {
const rows = buildRows('pr-status', [unpinned1, pinned], repoMap, null, new Set())
const pinnedHeader = rows.find((r) => r.type === 'header' && r.key === 'pinned')
expect(pinnedHeader).toBeDefined()
const prGroup = rows.filter((r) => r.type === 'header' && r.key.startsWith('pr:'))
for (const header of prGroup) {
if (header.type === 'header') {
- expect(header.count).toBe(2)
+ expect(header.count).toBe(1)
}
}
})
@@ -304,20 +354,14 @@ describe('buildRows with pinned worktrees', () => {
)
expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' })
expect(rows[1]).toMatchObject({ type: 'header', key: 'workspace-status:in-progress' })
- expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } })
- expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } })
+ expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } })
})
- it('keeps status sections complete when all worktrees are pinned', () => {
+ it('omits status sections when all matching worktrees are pinned', () => {
const allPinned = { ...unpinned1, isPinned: true }
const rows = buildRows('workspace-status', [pinned, allPinned], repoMap, null, new Set())
- expect(rows.filter((r) => r.type === 'header')).toHaveLength(2)
+ expect(rows.filter((r) => r.type === 'header')).toHaveLength(1)
expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned', count: 2 })
- expect(rows[3]).toMatchObject({
- type: 'header',
- key: 'workspace-status:in-progress',
- count: 2
- })
})
it('preserves repo display casing in group labels', () => {
@@ -798,7 +842,124 @@ describe('buildRows with pinned worktrees', () => {
expect(rows.some((row) => row.type === 'imported-worktrees-card')).toBe(false)
})
- it('emits imported worktree cards in repo groups when visible rows are pinned', () => {
+ it('places non-repo imported fallbacks after each repo last pinned row when expanded', () => {
+ const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' }
+ const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true }
+ const pinnedTwo = {
+ ...worktree,
+ id: 'repo-2-pinned',
+ repoId: repoTwo.id,
+ isPinned: true,
+ displayName: 'auth-main'
+ }
+ const pinnedOneB = { ...worktree, id: 'repo-1-pinned-b', isPinned: true }
+
+ const rows = buildRows(
+ 'none',
+ [pinnedOneA, pinnedTwo, pinnedOneB],
+ new Map([
+ [repo.id, repo],
+ [repoTwo.id, repoTwo]
+ ]),
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ undefined,
+ {},
+ new Map([
+ [pinnedOneA.id, pinnedOneA],
+ [pinnedTwo.id, pinnedTwo],
+ [pinnedOneB.id, pinnedOneB]
+ ]),
+ false,
+ undefined,
+ [],
+ new Set(),
+ new Map([
+ [repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-one' })] }],
+ [
+ repoTwo.id,
+ {
+ repo: repoTwo,
+ hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-two', repoId: repoTwo.id })]
+ }
+ ]
+ ])
+ )
+
+ expect(
+ rows.map((row) =>
+ row.type === 'item'
+ ? row.worktree.id
+ : row.type === 'imported-worktrees-card'
+ ? `${row.placement}:${row.repo.id}`
+ : row.key
+ )
+ ).toEqual([
+ 'pinned',
+ 'repo-1-pinned-a',
+ 'repo-2-pinned',
+ 'pinned-fallback:repo-2',
+ 'repo-1-pinned-b',
+ 'pinned-fallback:repo-1'
+ ])
+ })
+
+ it('places collapsed non-repo imported fallbacks after Pinned in pinned repo order', () => {
+ const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' }
+ const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true }
+ const pinnedTwo = {
+ ...worktree,
+ id: 'repo-2-pinned',
+ repoId: repoTwo.id,
+ isPinned: true,
+ displayName: 'auth-main'
+ }
+ const pinnedOneB = { ...worktree, id: 'repo-1-pinned-b', isPinned: true }
+
+ const rows = buildRows(
+ 'workspace-status',
+ [pinnedOneA, pinnedTwo, pinnedOneB],
+ new Map([
+ [repo.id, repo],
+ [repoTwo.id, repoTwo]
+ ]),
+ null,
+ new Set([PINNED_GROUP_KEY]),
+ undefined,
+ undefined,
+ undefined,
+ {},
+ new Map([
+ [pinnedOneA.id, pinnedOneA],
+ [pinnedTwo.id, pinnedTwo],
+ [pinnedOneB.id, pinnedOneB]
+ ]),
+ false,
+ undefined,
+ [],
+ new Set(),
+ new Map([
+ [repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-one' })] }],
+ [
+ repoTwo.id,
+ {
+ repo: repoTwo,
+ hiddenWorktrees: [makeDetectedWorktree({ id: 'hidden-two', repoId: repoTwo.id })]
+ }
+ ]
+ ])
+ )
+
+ expect(rows).toMatchObject([
+ { type: 'header', key: PINNED_GROUP_KEY, count: 3 },
+ { type: 'imported-worktrees-card', placement: 'pinned-fallback', repo: { id: repo.id } },
+ { type: 'imported-worktrees-card', placement: 'pinned-fallback', repo: { id: repoTwo.id } }
+ ])
+ })
+
+ it('emits imported worktree cards in repo groups when visible worktrees are pinned', () => {
const repoTwo: Repo = { ...repo, id: 'repo-2', displayName: 'auth-service' }
const pinnedOneA = { ...worktree, id: 'repo-1-pinned-a', isPinned: true }
const pinnedTwo = {
@@ -853,24 +1014,112 @@ describe('buildRows with pinned worktrees', () => {
placement: 'repo-group'
}
])
+ expect(
+ rows.filter((row) => row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY)
+ ).toEqual([])
expect(rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'item',
+ sectionKey: PINNED_GROUP_KEY,
worktree: expect.objectContaining({ id: 'repo-1-pinned-a' })
}),
expect.objectContaining({
type: 'item',
+ sectionKey: PINNED_GROUP_KEY,
worktree: expect.objectContaining({ id: 'repo-1-pinned-b' })
}),
expect.objectContaining({
type: 'item',
+ sectionKey: PINNED_GROUP_KEY,
worktree: expect.objectContaining({ id: 'repo-2-pinned' })
})
])
)
})
+ it('duplicates pinned worktrees into repo groups when the policy allows it', () => {
+ const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true }
+ const rows = buildRows(
+ 'repo',
+ [pinnedWorktree],
+ repoMap,
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ undefined,
+ {},
+ new Map([[pinnedWorktree.id, pinnedWorktree]]),
+ false,
+ { showPinnedWorktreesInGroups: true } as never,
+ [],
+ new Set(),
+ new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]])
+ )
+
+ expect(rows).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: 'item',
+ sectionKey: PINNED_GROUP_KEY,
+ worktree: expect.objectContaining({ id: 'wt-pinned' })
+ }),
+ expect.objectContaining({
+ type: 'item',
+ sectionKey: `repo:${repo.id}`,
+ worktree: expect.objectContaining({ id: 'wt-pinned' })
+ })
+ ])
+ )
+ })
+
+ it('suppresses duplicate-mode imported fallback only when a natural anchor renders', () => {
+ const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true }
+ const imported = new Map([[repo.id, { repo, hiddenWorktrees: [makeDetectedWorktree()] }]])
+ const expanded = buildRows(
+ 'none',
+ [pinnedWorktree],
+ repoMap,
+ null,
+ new Set(),
+ undefined,
+ undefined,
+ undefined,
+ {},
+ new Map([[pinnedWorktree.id, pinnedWorktree]]),
+ false,
+ { showPinnedWorktreesInGroups: true } as never,
+ [],
+ new Set(),
+ imported
+ )
+ const collapsedAll = buildRows(
+ 'none',
+ [pinnedWorktree],
+ repoMap,
+ null,
+ new Set(['all']),
+ undefined,
+ undefined,
+ undefined,
+ {},
+ new Map([[pinnedWorktree.id, pinnedWorktree]]),
+ false,
+ { showPinnedWorktreesInGroups: true } as never,
+ [],
+ new Set(),
+ imported
+ )
+
+ expect(expanded.some((row) => row.type === 'imported-worktrees-card')).toBe(false)
+ expect(collapsedAll).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ type: 'imported-worktrees-card', placement: 'pinned-fallback' })
+ ])
+ )
+ })
+
it('suppresses pinned imported worktree fallback when the repo has visible unpinned rows', () => {
const pinnedWorktree = { ...worktree, id: 'wt-pinned', isPinned: true }
const rows = buildRows(
@@ -922,9 +1171,11 @@ describe('buildRows with pinned worktrees', () => {
expect(rows).toMatchObject([
{ type: 'header', key: 'pinned' },
{ type: 'header', key: 'repo:repo-1' },
- { type: 'imported-worktrees-card', placement: 'repo-group' },
- { type: 'item', worktree: { id: 'wt-pinned' } }
+ { type: 'imported-worktrees-card', placement: 'repo-group' }
])
+ expect(
+ rows.filter((row) => row.type === 'item' && row.sectionKey !== PINNED_GROUP_KEY)
+ ).toEqual([])
})
it('groups folder-mode workspaces under their folder name', () => {
@@ -1292,6 +1543,63 @@ 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(),
+ [],
+ 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',
diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts
index 190ef5bcebc..4f8c863ec8a 100644
--- a/src/renderer/src/components/sidebar/worktree-list-groups.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts
@@ -34,11 +34,24 @@ import type { AppState } from '../../store/types'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key'
import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels'
import { translate } from '@/i18n/i18n'
-import { getExecutionHostLabel, getRepoExecutionHostId } from '../../../../shared/execution-host'
+import type { WorktreeSectionActivitySummary } from './worktree-section-activity'
+import {
+ getExecutionHostLabel,
+ LOCAL_EXECUTION_HOST_ID,
+ getRepoExecutionHostId,
+ type ExecutionHostId
+} from '../../../../shared/execution-host'
export { branchName }
export type WorktreeGroupBy = 'none' | 'workspace-status' | 'repo' | 'pr-status'
+export type PinnedWorktreeDisplayPolicy = 'single-location' | 'duplicate-in-groups'
+
+export function getPinnedWorktreeDisplayPolicy(
+ settings?: { showPinnedWorktreesInGroups?: boolean } | null
+): PinnedWorktreeDisplayPolicy {
+ return settings?.showPinnedWorktreesInGroups === true ? 'duplicate-in-groups' : 'single-location'
+}
export type GroupHeaderRow = {
type: 'header'
@@ -50,6 +63,12 @@ export type GroupHeaderRow = {
repo?: Repo
projectGroup?: ProjectGroup | { id: null; name: 'Ungrouped'; tabOrder: number }
projectGroupDepth?: number
+ hostId?: ExecutionHostId
+ hostWorktreeCounts?: ReadonlyMap
+ hostWorktreeIds?: ReadonlyMap
+ worktreeIds?: readonly string[]
+ runningCount?: number
+ hostRunningCounts?: ReadonlyMap
}
export type WorktreeRow = {
@@ -370,23 +389,43 @@ export function getPRGroupKey(
/**
* Emit a "Pinned" header + its items into `result`.
*
- * Why: pinned is a shortcut overlay, not the worktree's canonical grouping.
- * Normal sections still include pinned worktrees so labels like "All" and
- * "In progress" remain literal.
+ * Why: the dedicated Pinned section is always present for pinned worktrees;
+ * the display policy decides whether their natural group rows also render.
*/
function emitPinnedGroup(
worktrees: Worktree[],
repoMap: Map,
+ defaultHostId: ExecutionHostId,
collapsedGroups: Set,
- visibleUnpinnedRepoIds: ReadonlySet,
+ renderedNaturalAnchorRepoIds: ReadonlySet,
importedWorktreesByRepo: ReadonlyMap,
allowImportedFallback: boolean,
- result: Row[]
+ result: Row[],
+ activitySummary?: WorktreeSectionActivitySummary
): void {
const pinned = worktrees.filter((w) => w.isPinned)
if (pinned.length === 0) {
return
}
+ const hostWorktreeCounts = new Map()
+ const hostWorktreeIds = new Map()
+ const pinnedRepoOrder: string[] = []
+ const seenPinnedRepoIds = new Set()
+ for (const worktree of pinned) {
+ const repo = repoMap.get(worktree.repoId)
+ const hostId =
+ repo && (repo.connectionId || repo.executionHostId)
+ ? getRepoExecutionHostId(repo)
+ : defaultHostId
+ hostWorktreeCounts.set(hostId, (hostWorktreeCounts.get(hostId) ?? 0) + 1)
+ const hostIds = hostWorktreeIds.get(hostId) ?? []
+ hostIds.push(worktree.id)
+ hostWorktreeIds.set(hostId, hostIds)
+ if (!seenPinnedRepoIds.has(worktree.repoId)) {
+ pinnedRepoOrder.push(worktree.repoId)
+ seenPinnedRepoIds.add(worktree.repoId)
+ }
+ }
result.push({
type: 'header',
@@ -394,9 +433,21 @@ function emitPinnedGroup(
label: PINNED_GROUP_META.label,
count: pinned.length,
tone: PINNED_GROUP_META.tone,
- icon: PINNED_GROUP_META.icon
+ icon: PINNED_GROUP_META.icon,
+ hostWorktreeCounts,
+ hostWorktreeIds,
+ worktreeIds: pinned.map((worktree) => worktree.id),
+ runningCount: activitySummary?.runningCount,
+ hostRunningCounts: activitySummary?.hostRunningCounts
})
- if (!collapsedGroups.has(PINNED_GROUP_KEY)) {
+ if (collapsedGroups.has(PINNED_GROUP_KEY)) {
+ for (const repoId of pinnedRepoOrder) {
+ const candidate = importedWorktreesByRepo.get(repoId)
+ if (allowImportedFallback && candidate && !renderedNaturalAnchorRepoIds.has(repoId)) {
+ result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback'))
+ }
+ }
+ } else {
const lastPinnedIndexByRepoId = new Map()
pinned.forEach((worktree, index) => lastPinnedIndexByRepoId.set(worktree.repoId, index))
for (const [index, worktree] of pinned.entries()) {
@@ -416,7 +467,7 @@ function emitPinnedGroup(
if (
allowImportedFallback &&
candidate &&
- !visibleUnpinnedRepoIds.has(worktree.repoId) &&
+ !renderedNaturalAnchorRepoIds.has(worktree.repoId) &&
lastPinnedIndexByRepoId.get(worktree.repoId) === index
) {
result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback'))
@@ -610,6 +661,112 @@ function getMixedHostContextLabels(
return uniqueLabels.size > 1 ? labelsByRepoId : undefined
}
+function getWorktreeHostId(
+ worktree: Worktree,
+ repoMap: Map,
+ defaultHostId: ExecutionHostId
+): ExecutionHostId {
+ const repo = repoMap.get(worktree.repoId)
+ return repo && (repo.connectionId || repo.executionHostId)
+ ? getRepoExecutionHostId(repo)
+ : defaultHostId
+}
+
+function getHostWorktreeCounts(
+ worktrees: readonly Worktree[],
+ repoMap: Map,
+ defaultHostId: ExecutionHostId
+): Map | undefined {
+ if (worktrees.length === 0) {
+ return undefined
+ }
+ const counts = new Map()
+ const seenWorktreeIds = new Set()
+ for (const worktree of worktrees) {
+ if (seenWorktreeIds.has(worktree.id)) {
+ continue
+ }
+ seenWorktreeIds.add(worktree.id)
+ const hostId = getWorktreeHostId(worktree, repoMap, defaultHostId)
+ counts.set(hostId, (counts.get(hostId) ?? 0) + 1)
+ }
+ return counts
+}
+
+function getHostWorktreeIds(
+ worktrees: readonly Worktree[],
+ repoMap: Map,
+ defaultHostId: ExecutionHostId
+): Map | undefined {
+ if (worktrees.length === 0) {
+ return undefined
+ }
+ const idsByHost = new Map()
+ const seenWorktreeIds = new Set()
+ for (const worktree of worktrees) {
+ if (seenWorktreeIds.has(worktree.id)) {
+ continue
+ }
+ seenWorktreeIds.add(worktree.id)
+ const hostId = getWorktreeHostId(worktree, repoMap, defaultHostId)
+ const ids = idsByHost.get(hostId) ?? []
+ ids.push(worktree.id)
+ idsByHost.set(hostId, ids)
+ }
+ return idsByHost
+}
+
+function getRenderedNaturalAnchorRepoIds({
+ groupBy,
+ worktrees,
+ repoMap,
+ prCache,
+ collapsedGroups,
+ workspaceStatuses,
+ settings,
+ projectGrouping
+}: {
+ groupBy: WorktreeGroupBy
+ worktrees: readonly Worktree[]
+ repoMap: Map
+ prCache: Record | null
+ collapsedGroups: ReadonlySet
+ workspaceStatuses: readonly WorkspaceStatusDefinition[]
+ settings?: AppState['settings']
+ projectGrouping?: ProjectGroupingModel
+}): Set {
+ const renderedRepoIds = new Set()
+ if (groupBy === 'none') {
+ if (!collapsedGroups.has(ALL_GROUP_KEY)) {
+ for (const worktree of worktrees) {
+ renderedRepoIds.add(worktree.repoId)
+ }
+ }
+ return renderedRepoIds
+ }
+ if (groupBy === 'repo') {
+ for (const worktree of worktrees) {
+ renderedRepoIds.add(worktree.repoId)
+ }
+ return renderedRepoIds
+ }
+ for (const worktree of worktrees) {
+ const groupKey = getGroupKeyForWorktree(
+ groupBy,
+ worktree,
+ repoMap,
+ prCache,
+ workspaceStatuses,
+ settings,
+ projectGrouping
+ )
+ if (groupKey && !collapsedGroups.has(groupKey)) {
+ renderedRepoIds.add(worktree.repoId)
+ }
+ }
+ return renderedRepoIds
+}
+
function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] {
const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree)
if (mainWorktrees.length === 0) {
@@ -771,7 +928,10 @@ export function buildRows(
pendingCreations: readonly PendingCreationRef[] = [],
projectGrouping?: ProjectGroupingModel,
folderWorkspaces: readonly FolderWorkspace[] = [],
- hostLabelById?: ReadonlyMap
+ hostLabelById?: ReadonlyMap,
+ defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID,
+ pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings),
+ sectionActivitySummaries?: ReadonlyMap
): Row[] {
const result: Row[] = []
const projectIndex = buildProjectGroupingIndex(projectGrouping)
@@ -792,34 +952,55 @@ export function buildRows(
}
}
- const visibleUnpinnedRepoIds = new Set(
- worktrees.filter((worktree) => !worktree.isPinned).map((worktree) => worktree.repoId)
- )
- const visiblePinnedRepoIds = new Set(
- worktrees.filter((worktree) => worktree.isPinned).map((worktree) => worktree.repoId)
- )
+ const naturalWorktrees =
+ pinnedDisplayPolicy === 'duplicate-in-groups'
+ ? worktrees
+ : worktrees.filter((worktree) => !worktree.isPinned)
+ const renderedNaturalAnchorRepoIds = getRenderedNaturalAnchorRepoIds({
+ groupBy,
+ worktrees: naturalWorktrees,
+ repoMap,
+ prCache,
+ collapsedGroups,
+ workspaceStatuses,
+ settings,
+ projectGrouping
+ })
+ const getSectionActivity = (
+ key: string
+ ): Pick => {
+ const summary = sectionActivitySummaries?.get(key)
+ return summary && summary.runningCount > 0
+ ? { runningCount: summary.runningCount, hostRunningCounts: summary.hostRunningCounts }
+ : {}
+ }
emitPinnedGroup(
worktrees,
repoMap,
+ defaultHostId,
collapsedGroups,
- visibleUnpinnedRepoIds,
+ renderedNaturalAnchorRepoIds,
importedWorktreesByRepo,
groupBy !== 'repo',
- result
+ result,
+ sectionActivitySummaries?.get(PINNED_GROUP_KEY)
)
-
if (groupBy === 'none') {
- if (worktrees.length > 0) {
+ if (naturalWorktrees.length > 0) {
result.push({
type: 'header',
key: ALL_GROUP_KEY,
label: ALL_GROUP_META.label,
- count: worktrees.length,
+ count: naturalWorktrees.length,
tone: ALL_GROUP_META.tone,
- icon: ALL_GROUP_META.icon
+ 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)
})
if (!collapsedGroups.has(ALL_GROUP_KEY)) {
- appendWorktreeRows(result, worktrees, repoMap, lineageById, worktreeMap, {
+ appendWorktreeRows(result, naturalWorktrees, repoMap, lineageById, worktreeMap, {
nestLineage,
collapsedGroups,
groupDepth: 0,
@@ -831,7 +1012,7 @@ export function buildRows(
}
const grouped = new Map()
- for (const w of worktrees) {
+ for (const w of naturalWorktrees) {
let key: string
let label: string
let repo: Repo | undefined
@@ -882,7 +1063,7 @@ export function buildRows(
for (const [repoId, candidate] of importedWorktreesByRepo) {
const grouping = getProjectGroupingForRepo(repoId, repoMap, projectIndex)
const key = grouping.key
- if (!grouped.has(key) && !visiblePinnedRepoIds.has(repoId)) {
+ if (!grouped.has(key)) {
grouped.set(key, {
label: grouping.label,
items: [],
@@ -984,7 +1165,11 @@ export function buildRows(
label: definition?.label ?? workspaceStatus,
count: group.items.length,
tone: meta.tone,
- icon: meta.icon
+ icon: meta.icon,
+ hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId),
+ hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId),
+ worktreeIds: group.items.map((worktree) => worktree.id),
+ ...getSectionActivity(key)
}
})()
: (() => {
@@ -996,7 +1181,11 @@ export function buildRows(
label: meta.label,
count: group.items.length,
tone: meta.tone,
- icon: meta.icon
+ icon: meta.icon,
+ hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId),
+ hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId),
+ worktreeIds: group.items.map((worktree) => worktree.id),
+ ...getSectionActivity(key)
}
})()
@@ -1128,7 +1317,8 @@ export function buildRows(
tone: PROJECT_GROUP_META.tone,
icon: PROJECT_GROUP_META.icon,
projectGroup,
- projectGroupDepth: depth
+ projectGroupDepth: depth,
+ ...getSectionActivity(key)
})
if (!collapsedGroups.has(key)) {
for (const folderWorkspace of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) {
diff --git a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts
index 2bb4e44a9dd..67f3760c91c 100644
--- a/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-list-imported-rows.test.ts
@@ -3,6 +3,7 @@ import {
canKeepImportedWorktreesHidden,
getRenderRowKey,
getWorktreeDragGroups,
+ getWorktreeDragIndexes,
renderRowContainsWorktree
} from './WorktreeList'
import type { Repo, Worktree } from '../../../../shared/types'
@@ -59,6 +60,13 @@ const makeWorktreeRow = (id: string): Extract => ({
lineageChildCount: 0
})
+const makePinnedWorktreeRow = (id: string): Extract => ({
+ ...makeWorktreeRow(id),
+ rowKey: `pinned:${id}`,
+ sectionKey: 'pinned',
+ worktree: { ...makeWorktree(id), isPinned: true }
+})
+
const makeImportedCardRow = (): Extract => ({
type: 'imported-worktrees-card',
key: 'imported-worktrees-card:repo-group:repo-1',
@@ -86,6 +94,29 @@ describe('imported worktree virtual rows', () => {
).toEqual([{ key: 'repo:repo-1', worktreeIds: ['main', 'feature'] }])
})
+ it('indexes pinned rows for sidebar drag when they are the only rendered copy', () => {
+ const rows = [makeHeaderRow('pinned'), makePinnedWorktreeRow('main')]
+ const { groupIndexByRowKey, groupKeyByRowKey } = getWorktreeDragIndexes(rows)
+
+ expect(getWorktreeDragGroups(rows)).toEqual([{ key: 'pinned', worktreeIds: ['main'] }])
+ expect(groupKeyByRowKey.get('pinned:main')).toBe('pinned')
+ expect(groupIndexByRowKey.get('pinned:main')).toBe(0)
+ })
+
+ it('uses natural drag metadata when pinned rows have duplicate natural copies', () => {
+ const rows = [
+ makeHeaderRow('pinned'),
+ makePinnedWorktreeRow('main'),
+ makeHeaderRow('all'),
+ makeWorktreeRow('main')
+ ]
+ const { groupKeyByRowKey } = getWorktreeDragIndexes(rows)
+
+ expect(getWorktreeDragGroups(rows)).toEqual([{ key: 'all', worktreeIds: ['main'] }])
+ expect(groupKeyByRowKey.has('pinned:main')).toBe(false)
+ expect(groupKeyByRowKey.get('all:main')).toBe('all')
+ })
+
it('only allows keep-hidden actions for repo-group cards that are not forced visible', () => {
expect(canKeepImportedWorktreesHidden(makeImportedCardRow(), undefined)).toBe(true)
expect(
diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.test.ts b/src/renderer/src/components/sidebar/worktree-section-activity.test.ts
index 5de876fedb3..b503ecf25d2 100644
--- a/src/renderer/src/components/sidebar/worktree-section-activity.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-section-activity.test.ts
@@ -1,10 +1,19 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
-import type { ProjectGroup, Repo, TerminalTab, Worktree } from '../../../../shared/types'
+import type {
+ Project,
+ ProjectGroup,
+ ProjectHostSetup,
+ Repo,
+ TerminalTab,
+ Worktree
+} from '../../../../shared/types'
import {
getProjectGroupHeaderKey,
PINNED_GROUP_KEY,
+ type PinnedWorktreeDisplayPolicy,
+ type ProjectGroupingModel,
type WorktreeGroupBy
} from './worktree-list-groups'
import {
@@ -91,6 +100,7 @@ function makeState(
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {},
+ runtimeAgentOrchestrationByPaneKey: {},
...overrides
}
}
@@ -100,12 +110,18 @@ 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[0]['defaultHostId']
state: WorktreeSectionActivityState
}) {
return buildWorktreeSectionActivitySummaries({
@@ -115,6 +131,9 @@ function buildSummaries({
prCache: null,
workspaceStatuses: [],
projectGroups,
+ projectGrouping,
+ pinnedDisplayPolicy,
+ defaultHostId,
state
})
}
@@ -148,17 +167,20 @@ describe('buildWorktreeSectionActivitySummaries', () => {
})
expect(summaries.get(getProjectGroupHeaderKey(parent.id))).toEqual({
- runningCount: 1
+ runningCount: 1,
+ hostRunningCounts: new Map([['local', 1]])
})
expect(summaries.get(getProjectGroupHeaderKey(child.id))).toEqual({
- runningCount: 1
+ runningCount: 1,
+ hostRunningCounts: new Map([['local', 1]])
})
expect(summaries.get(`repo:${repo.id}`)).toEqual({
- runningCount: 1
+ runningCount: 1,
+ hostRunningCounts: new Map([['local', 1]])
})
})
- it('counts pinned workspace activity on pinned and natural headers', () => {
+ it('counts pinned workspace activity only on the pinned header', () => {
const repo = makeRepo({ projectGroupId: 'group-1' })
const worktree = makeWorktree({ repoId: repo.id, isPinned: true })
const now = Date.now()
@@ -187,14 +209,170 @@ describe('buildWorktreeSectionActivitySummaries', () => {
state
})
- expect(summaries.get(PINNED_GROUP_KEY)).toEqual({
- runningCount: 1
+ 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(`repo:${repo.id}`)).toEqual({
- runningCount: 1
+
+ const summaries = buildSummaries({
+ worktrees: [worktree],
+ repos: [repo],
+ projectGroups: [makeProjectGroup({ id: 'group-1' })],
+ pinnedDisplayPolicy: 'duplicate-in-groups',
+ state
})
- expect(summaries.get(getProjectGroupHeaderKey('group-1'))).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']
+ }
})
+
+ 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]
+ ])
+ )
})
})
diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.ts b/src/renderer/src/components/sidebar/worktree-section-activity.ts
index fb81da4f0cf..e36ebc984fa 100644
--- a/src/renderer/src/components/sidebar/worktree-section-activity.ts
+++ b/src/renderer/src/components/sidebar/worktree-section-activity.ts
@@ -8,9 +8,17 @@ 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 {
@@ -28,6 +36,7 @@ export type WorktreeSectionActivityState = Pick<
| 'agentStatusByPaneKey'
| 'migrationUnsupportedByPtyId'
| 'retainedAgentsByPaneKey'
+ | 'runtimeAgentOrchestrationByPaneKey'
> & {
tabsByWorktree: Record[]>
browserTabsByWorktree: Record
@@ -36,6 +45,7 @@ export type WorktreeSectionActivityState = Pick<
export type WorktreeSectionActivitySummary = {
runningCount: number
+ hostRunningCounts?: ReadonlyMap
}
export const EMPTY_WORKTREE_SECTION_ACTIVITY: WorktreeSectionActivitySummary = {
@@ -50,6 +60,9 @@ export function buildWorktreeSectionActivitySummaries({
workspaceStatuses,
settings,
projectGroups,
+ projectGrouping,
+ pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings),
+ defaultHostId = LOCAL_EXECUTION_HOST_ID,
state
}: {
groupBy: WorktreeGroupBy
@@ -59,23 +72,30 @@ export function buildWorktreeSectionActivitySummaries({
workspaceStatuses: readonly WorkspaceStatusDefinition[]
settings?: AppState['settings']
projectGroups: readonly ProjectGroup[]
+ projectGrouping?: ProjectGroupingModel
+ pinnedDisplayPolicy?: PinnedWorktreeDisplayPolicy
+ defaultHostId?: ExecutionHostId
state: WorktreeSectionActivityState
}): Map {
const summaries = new Map()
for (const worktree of worktrees) {
- const groupKeys = [
- ...(worktree.isPinned ? [PINNED_GROUP_KEY] : []),
- ...getGroupKeysForWorktree(
- groupBy,
- worktree,
- repoMap,
- prCache,
- workspaceStatuses,
- settings,
- projectGroups
- )
- ]
+ 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
if (groupKeys.length === 0) {
continue
}
@@ -85,6 +105,10 @@ 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)
}
@@ -93,6 +117,20 @@ export function buildWorktreeSectionActivitySummaries({
return summaries
}
+function getWorktreeHostId(
+ worktree: Worktree,
+ repoMap: ReadonlyMap,
+ 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
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 0c6fdfd6a65..0718a524cb2 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -4523,7 +4523,11 @@
"tintOpacity": "Tint Strength",
"tintOpacityDescription": "Controls how strongly the tint is mixed into the sidebar."
},
- "workspaceCardLayoutGuidance": "Use the workspace sidebar options menu > Card layout > Compact."
+ "workspaceCardLayoutGuidance": "Use the workspace sidebar options menu > Card layout > Compact.",
+ "showPinnedWorktreesInGroups": {
+ "title": "Show pinned worktrees in other groups",
+ "description": "Pinned worktrees also appear in All, Project, Status, and PR groups."
+ }
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@@ -6682,6 +6686,10 @@
"cardLayout": "card layout",
"workspaceOptions": "workspace options",
"detailed": "detailed"
+ },
+ "showPinnedWorktreesInGroups": {
+ "title": "Show pinned worktrees in other groups",
+ "description": "Pinned worktrees also appear in All, Project, Status, and PR groups."
}
}
},
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 590f5bc91a8..9e3ec2e781d 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -4523,7 +4523,11 @@
"workspaceCardLayoutGuidance": "Usa el menú de opciones de la barra lateral de espacios de trabajo > Diseño de tarjeta > Compacto.",
"872af9556e": "Bandeja del sistema",
"2edf606c46": "Minimizar a la bandeja al cerrar",
- "b707773a0d": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir."
+ "b707773a0d": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir.",
+ "showPinnedWorktreesInGroups": {
+ "title": "Mostrar worktrees fijados en otros grupos",
+ "description": "Los worktrees fijados también aparecen en los grupos Todos, Proyecto, Estado y PR."
+ }
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@@ -6645,7 +6649,11 @@
"detailed": "detallado"
},
"9a115966d3": "Minimizar a la bandeja al cerrar",
- "4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir."
+ "4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir.",
+ "showPinnedWorktreesInGroups": {
+ "title": "Mostrar worktrees fijados en otros grupos",
+ "description": "Los worktrees fijados también aparecen en los grupos Todos, Proyecto, Estado y PR."
+ }
}
},
"auto": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 75555ae1548..76d0e30da47 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -4508,7 +4508,11 @@
"workspaceCardLayoutGuidance": "ワークスペースサイドバーのオプションメニュー > カードレイアウト > コンパクト を使用します。",
"872af9556e": "システムトレイ",
"2edf606c46": "閉じるときにトレイへ最小化",
- "b707773a0d": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。"
+ "b707773a0d": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。",
+ "showPinnedWorktreesInGroups": {
+ "title": "ピン留めしたワークツリーを他のグループにも表示",
+ "description": "ピン留めしたワークツリーも、すべて、プロジェクト、ステータス、PR グループに表示されます。"
+ }
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@@ -6667,7 +6671,11 @@
"detailed": "詳細"
},
"9a115966d3": "閉じるときにトレイへ最小化",
- "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。"
+ "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。",
+ "showPinnedWorktreesInGroups": {
+ "title": "ピン留めしたワークツリーを他のグループにも表示",
+ "description": "ピン留めしたワークツリーも、すべて、プロジェクト、ステータス、PR グループに表示されます。"
+ }
}
},
"auto": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 8b6dc42fd9a..222de9cf3f1 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -4508,7 +4508,11 @@
"tintOpacity": "색조 강도",
"tintOpacityDescription": "사이드바에 색조를 얼마나 강하게 섞을지 조절합니다."
},
- "workspaceCardLayoutGuidance": "워크스페이스 사이드바 옵션 메뉴 > 카드 레이아웃 > 컴팩트를 사용하세요."
+ "workspaceCardLayoutGuidance": "워크스페이스 사이드바 옵션 메뉴 > 카드 레이아웃 > 컴팩트를 사용하세요.",
+ "showPinnedWorktreesInGroups": {
+ "title": "고정된 워크트리를 다른 그룹에도 표시",
+ "description": "고정된 워크트리는 전체, 프로젝트, 상태 및 PR 그룹에도 표시됩니다."
+ }
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@@ -6630,6 +6634,10 @@
"cardLayout": "카드 레이아웃",
"workspaceOptions": "워크스페이스 옵션",
"detailed": "상세"
+ },
+ "showPinnedWorktreesInGroups": {
+ "title": "고정된 워크트리를 다른 그룹에도 표시",
+ "description": "고정된 워크트리는 전체, 프로젝트, 상태 및 PR 그룹에도 표시됩니다."
}
}
},
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 83e32e2934e..25b1cf515b7 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -4508,7 +4508,11 @@
"workspaceCardLayoutGuidance": "使用工作区侧边栏选项菜单 > 卡片布局 > 紧凑。",
"872af9556e": "系统托盘",
"2edf606c46": "关闭时最小化到托盘",
- "b707773a0d": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。"
+ "b707773a0d": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。",
+ "showPinnedWorktreesInGroups": {
+ "title": "在其他分组中显示已固定的工作树",
+ "description": "已固定的工作树也会显示在全部、项目、状态和 PR 分组中。"
+ }
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@@ -6630,7 +6634,11 @@
"detailed": "详细"
},
"9a115966d3": "关闭时最小化到托盘",
- "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。"
+ "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。",
+ "showPinnedWorktreesInGroups": {
+ "title": "在其他分组中显示已固定的工作树",
+ "description": "已固定的工作树也会显示在全部、项目、状态和 PR 分组中。"
+ }
}
},
"auto": {
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 6a57d5fb3dd..1eddc47f7d0 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -271,6 +271,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
showTasksButton: true,
showAutomationsButton: true,
showMobileButton: true,
+ showPinnedWorktreesInGroups: false,
ctrlTabOrderMode: 'mru',
// Why: switching worktrees and opening command surfaces from a focused
// terminal is a core Orca workflow; users who prefer TUI ownership opt in.
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 1ddd0af588e..33ace946b43 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -2518,6 +2518,9 @@ export type GlobalSettings = {
/** Why: Orca Mobile remains reachable from the toolbox; this only controls
* whether the top-level sidebar shortcut is shown. */
showMobileButton?: boolean
+ /** Why: pinned workspaces default to one sidebar location; users can opt
+ * back into seeing them in their natural groups too. */
+ showPinnedWorktreesInGroups?: boolean
/** Controls how Ctrl+Tab chooses the next visible tab. Optional for
* profiles saved before this setting existed; readers default to MRU. */
ctrlTabOrderMode?: CtrlTabOrderMode