fix(sidebar): keep project headers from overlapping the pinned host (#26775)

The host header lays out as a 32px card in 4px top padding (36px), but
estimateRenderRowSize sized the row at 32px. The group tier's 36px pin
offset then fired 4px early, so a project header could pin while its
in-flow copy was still on screen — two headers at one coordinate.

Size the host row 36px so it matches the pinned slot, and resolve each
sticky handoff from the row's real start offset instead of only the
lagging range start, so a header pins exactly when it reaches the slot.
This commit is contained in:
Jinjing
2026-10-09 00:09:06 -07:00
committed by GitHub
parent cc2ad7b188
commit 69cf557a69
2 changed files with 92 additions and 5 deletions
@@ -4,6 +4,7 @@ import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import {
HOST_STICKY_PINNED_HEIGHT,
buildLineageRowRekeyMap,
estimateRenderRowSize,
extractWorktreeVirtualRowIndexes,
getActiveStickyIndexesForScroll,
getStickyHeaderIndexes,
@@ -77,6 +78,75 @@ describe('getRenderRowKey', () => {
})
describe('getActiveStickyIndexesForScroll', () => {
it('hands off adjacent collapsed project headers at the slot below the host', () => {
const adjacentRows = [hostRow('local'), groupRow('collapsed'), groupRow('incoming')]
const items = [virtualItem(0, 0), virtualItem(1, 42), virtualItem(2, 80)]
const resolve = (scrollOffset: number) =>
getActiveStickyIndexesForScroll({
rows: adjacentRows,
rangeStartIndex: 1,
scrollOffset,
stickyHeaderIndexes: getStickyHeaderIndexes(adjacentRows),
virtualItems: items
})
expect(resolve(43)).toEqual({ hostIndex: 0, groupIndex: 1 })
expect(resolve(44)).toEqual({ hostIndex: 0, groupIndex: 2 })
expect(resolve(43)).toEqual({ hostIndex: 0, groupIndex: 1 })
})
it('pins the first project once it reaches the host slot while the range still starts at the host', () => {
const firstRows = [hostRow('local'), groupRow('first')]
expect(
getActiveStickyIndexesForScroll({
rows: firstRows,
rangeStartIndex: 0,
scrollOffset: 6,
stickyHeaderIndexes: getStickyHeaderIndexes(firstRows),
virtualItems: [virtualItem(0, 0), virtualItem(1, 42)]
})
).toEqual({ hostIndex: 0, groupIndex: 1 })
})
it('selects the last header at the slot after crossing several collapsed groups', () => {
const adjacentRows = [
hostRow('local'),
groupRow('first'),
groupRow('second'),
groupRow('third')
]
expect(
getActiveStickyIndexesForScroll({
rows: adjacentRows,
rangeStartIndex: 0,
scrollOffset: 82,
stickyHeaderIndexes: getStickyHeaderIndexes(adjacentRows),
virtualItems: [
virtualItem(0, 0),
virtualItem(1, 42),
virtualItem(2, 80),
virtualItem(3, 118)
]
})
).toEqual({ hostIndex: 0, groupIndex: 3 })
})
it.each([
{ scrollOffset: 399, hostIndex: 0, groupIndex: 1 },
{ scrollOffset: 400, hostIndex: 4, groupIndex: null },
{ scrollOffset: 464, hostIndex: 4, groupIndex: 5 }
])('resolves host transitions with a lagging range at $scrollOffset', (expected) => {
expect(
getActiveStickyIndexesForScroll({
rows,
rangeStartIndex: 3,
scrollOffset: expected.scrollOffset,
stickyHeaderIndexes,
virtualItems
})
).toEqual({ hostIndex: expected.hostIndex, groupIndex: expected.groupIndex })
})
it('pins the host and its inner group while scrolled inside a section', () => {
expect(
getActiveStickyIndexesForScroll({
@@ -195,6 +265,14 @@ describe('getActiveStickyIndexesForScroll', () => {
})
})
describe('host header row height', () => {
it('reserves the host card height and its padding before the next row', () => {
const hostRows = [hostRow('local'), groupRow('first'), hostRow('ssh:remote')]
expect(estimateRenderRowSize(hostRows, 0, 0, null)).toBe(36)
expect(estimateRenderRowSize(hostRows, 2, 0, null)).toBe(40)
})
})
describe('extractWorktreeVirtualRowIndexes', () => {
it('keeps the pinned host mounted even when scrolled out of range', () => {
const indexes = extractWorktreeVirtualRowIndexes({
@@ -5,7 +5,8 @@ import { getRenderRowKey } from '../listing/render-row'
import type { RenderRow } from '../listing/render-row'
export const GROUP_HEADER_ROW_HEIGHT = 28
export const HOST_HEADER_ROW_HEIGHT = 32
// Why: HostSectionHeader wraps its h-8 (32px) card in pt-1 (4px), so the row is 36px.
export const HOST_HEADER_ROW_HEIGHT = 36
export const WORKTREE_SIDEBAR_VIRTUAL_ROW_GAP = 6
const SECONDARY_GROUP_HEADER_TOP_MARGIN = 4
const IMPORTED_WORKTREES_LINE_ROW_HEIGHT = 36
@@ -162,7 +163,7 @@ export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] {
// Why: the pinned host card is h-8 (32px) inside a pt-1 (4px) wrapper; the
// group tier pins one pixel up to sit flush beneath it. Keep in sync with
// HostSectionHeader's layout.
export const HOST_STICKY_PINNED_HEIGHT = 36
export const HOST_STICKY_PINNED_HEIGHT = HOST_HEADER_ROW_HEIGHT
export type ActiveStickyIndexes = {
/** Pinned host card (tier 1), or null outside host sections. */
@@ -186,17 +187,25 @@ export function getActiveStickyIndexesForScroll(args: {
virtualItems: readonly VirtualItem[]
}): ActiveStickyIndexes {
const hostIndexes = getHostStickyIndexes(args.rows, args.stickyHeaderIndexes)
const virtualItemsByIndex = new Map(args.virtualItems.map((item) => [item.index, item]))
const resolveWithHandoff = (
candidates: readonly number[],
pinnedOffset: number,
fallbackToCandidate: boolean
): number | null => {
const candidateIndex = getActiveStickyHeaderIndex(candidates, args.rangeStartIndex)
// The group slot can reach a header before the viewport's range start does.
const candidateIndex =
candidates.findLast(
(index) =>
index <= args.rangeStartIndex ||
(virtualItemsByIndex.get(index)?.start ?? Number.POSITIVE_INFINITY) <=
args.scrollOffset + pinnedOffset
) ?? null
if (candidateIndex === null) {
return null
}
const candidate = args.virtualItems.find((item) => item.index === candidateIndex)
const candidate = virtualItemsByIndex.get(candidateIndex)
if (!candidate) {
// Why: scrollToIndex/reveal can advance rangeStartIndex before TanStack
// mounts the candidate row. Pinning without geometry lets a Project
@@ -204,7 +213,7 @@ export function getActiveStickyIndexesForScroll(args: {
// sticky; group tier waits for geometry, host tier may keep the id.
const previous = getPreviousStickyHeaderIndex(candidates, candidateIndex)
if (previous !== null) {
const previousItem = args.virtualItems.find((item) => item.index === previous)
const previousItem = virtualItemsByIndex.get(previous)
if (previousItem) {
return previous
}