fix(sidebar): label pinned rows with their host on a multi-host sidebar

emitPinnedGroup was the one section emitter that appended worktree rows
without hostContextLabelByWorktreeIdentity, and the mixed-host map it
would have received was computed over naturalWorktrees, which under the
default pinned policy has the pinned worktrees filtered out. Under that
policy a pinned worktree renders only in the Pinned section, so a pinned
remote workspace had no host badge anywhere.

Compute the mixed-host map over the full worktree set and thread it into
the Pinned emitter. Single-host sidebars still draw no badge.

Fixes #18472
This commit is contained in:
Neil
2026-09-07 13:53:04 -07:00
parent bcd4076dd1
commit 4045ff0be7
3 changed files with 128 additions and 3 deletions
@@ -0,0 +1,119 @@
/**
* #18472: a pinned worktree on a multi-host sidebar lost its host badge. Under
* the default pinned policy it renders only in the Pinned section, so that was
* its only chance at host attribution.
*/
import { describe, expect, it } from 'vitest'
import { buildRows } from './worktree-list/grouping/build-rows'
import type { Row } from './worktree-list/grouping/row-types'
import {
LOCAL_HOST_LABEL,
repo,
worktree,
remoteRepo,
remoteWorktree
} from './worktree-list-groups-test-fixtures'
import type { Worktree } from '../../../../shared/worktree/types'
const hostLabelById = new Map([
['local', LOCAL_HOST_LABEL],
['ssh:gpu-vm', 'gpu-vm']
])
function buildPinnedRows(
worktrees: Worktree[],
groupBy: 'none' | 'repo' | 'workspace-status' = 'repo',
showPinnedWorktreesInGroups = false
): Row[] {
return buildRows(
groupBy,
worktrees,
new Map([
[repo.id, repo],
[remoteRepo.id, remoteRepo]
]),
null,
new Set(),
undefined,
undefined,
undefined,
{},
new Map(worktrees.map((candidate) => [candidate.id, candidate])),
false,
{ showPinnedWorktreesInGroups } as never,
[],
new Set(),
new Map(),
new Map(),
[],
undefined,
[],
hostLabelById
)
}
function itemRows(rows: Row[]): { id: string; sectionKey: string; hostContextLabel?: string }[] {
return rows.flatMap((row) =>
row.type === 'item'
? [
{
id: row.worktree.id,
sectionKey: row.sectionKey,
hostContextLabel: row.hostContextLabel
}
]
: []
)
}
describe('pinned rows on a multi-host sidebar', () => {
it.each(['repo', 'workspace-status', 'none'] as const)(
'labels a pinned remote worktree in the Pinned section (%s grouping)',
(groupBy) => {
const pinnedRemote: Worktree = { ...remoteWorktree, isPinned: true }
const rows = itemRows(buildPinnedRows([worktree, pinnedRemote], groupBy))
// Default policy: the pinned row is the only row for that worktree.
expect(rows.filter((row) => row.id === pinnedRemote.id)).toEqual([
{ id: pinnedRemote.id, sectionKey: 'pinned', hostContextLabel: 'gpu-vm' }
])
expect(rows.find((row) => row.id === worktree.id)?.hostContextLabel).toBe(LOCAL_HOST_LABEL)
}
)
it('labels pinned rows when the only other host is itself pinned', () => {
// Why: the natural lane holds one host here, so a map scoped to it would say
// "not mixed" even though the sidebar shows two hosts.
const pinnedLocal: Worktree = { ...worktree, isPinned: true }
const pinnedRemote: Worktree = { ...remoteWorktree, isPinned: true }
const localOnly: Worktree = { ...worktree, id: 'wt-local-2', displayName: 'local-2' }
const rows = itemRows(buildPinnedRows([pinnedLocal, pinnedRemote, localOnly]))
expect(rows).toEqual([
{ id: pinnedLocal.id, sectionKey: 'pinned', hostContextLabel: LOCAL_HOST_LABEL },
{ id: pinnedRemote.id, sectionKey: 'pinned', hostContextLabel: 'gpu-vm' },
{ id: localOnly.id, sectionKey: 'repo:repo-1', hostContextLabel: LOCAL_HOST_LABEL }
])
})
it('labels both copies when pinned worktrees also show in their groups', () => {
const pinnedRemote: Worktree = { ...remoteWorktree, isPinned: true }
const rows = itemRows(buildPinnedRows([worktree, pinnedRemote], 'repo', true))
expect(rows.filter((row) => row.id === pinnedRemote.id)).toEqual([
{ id: pinnedRemote.id, sectionKey: 'pinned', hostContextLabel: 'gpu-vm' },
{ id: pinnedRemote.id, sectionKey: 'repo:repo-remote', hostContextLabel: 'gpu-vm' }
])
})
it('draws no badge on a single-host sidebar even with a pinned row', () => {
const pinnedLocal: Worktree = { ...worktree, isPinned: true }
const localOnly: Worktree = { ...worktree, id: 'wt-local-2', displayName: 'local-2' }
const rows = itemRows(buildPinnedRows([pinnedLocal, localOnly]))
expect(rows).toHaveLength(2)
for (const row of rows) {
expect(row.hostContextLabel).toBeUndefined()
}
})
})
@@ -105,8 +105,11 @@ export function buildRows(
pinnedDisplayPolicy === 'duplicate-in-groups'
? worktrees
: worktrees.filter((worktree) => !pinnedSectionIds.has(getWorktreeHostIdentity(worktree)))
// Why the full set: under the default pinned policy a pinned worktree exists
// only in the Pinned section, and its host is part of whether the sidebar is
// mixed at all. Scoping to naturalWorktrees left pinned remotes unlabelled.
const mixedWorktreeHostContextLabels = getMixedWorktreeHostContextLabels(
naturalWorktrees,
worktrees,
repoMap,
hostLabelById,
defaultHostId
@@ -145,7 +148,8 @@ export function buildRows(
worktreeMap,
nestLineage,
cyclicLineageIds,
noticeHostContextLabelByRepoId
noticeHostContextLabelByRepoId,
mixedWorktreeHostContextLabels
)
if (groupBy === 'none') {
// Why folder workspaces gate this too: an account with only folder
@@ -27,7 +27,8 @@ export function emitPinnedGroup(
worktreeMap: Map<string, Worktree>,
nestLineage: boolean,
cyclicLineageIds: ReadonlySet<string>,
noticeHostContextLabelByRepoId?: ReadonlyMap<string, NoticeHostContext>
noticeHostContextLabelByRepoId?: ReadonlyMap<string, NoticeHostContext>,
hostContextLabelByWorktreeIdentity?: ReadonlyMap<string, string>
): void {
if (pinnedSectionWorktrees.length === 0) {
return
@@ -81,6 +82,7 @@ export function emitPinnedGroup(
collapsedGroups,
groupDepth: 0,
sectionKey: PINNED_GROUP_KEY,
hostContextLabelByWorktreeIdentity,
cyclicLineageIds
})
if (!allowImportedFallback) {