Two-tier sticky headers: pinned host card above pinned group header

When scrolling inside a host section, the host card now stays pinned at
the top (z-30) while project/status group headers hand off beneath it
(z-20, offset by the pinned card height). The host is the outer
hierarchy level, so it is the most persistent context — previously the
first repo header replaced it, losing 'which machine am I on' exactly
when it mattered. The pinned card keeps its collapse/menu/warning
affordances. Handoff rules: the next host card pushes the previous one
out at the viewport top; a group pins only once it reaches the slot
beneath the host card, and a previous host's group can never pin under
the next host. Without host sections the logic degrades to the original
single-tier behavior.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-11 12:32:56 -07:00
co-authored by Orca
parent e70820a055
commit eb456a94e6
3 changed files with 257 additions and 11 deletions
@@ -78,7 +78,7 @@ import {
import {
estimateRenderRowSize,
extractWorktreeVirtualRowIndexes,
getActiveStickyHeaderIndexForScroll,
getActiveStickyIndexesForScroll,
getStickyHeaderIndexes,
getVirtualRowTransform,
shouldUseHeaderTopSpacing,
@@ -1125,6 +1125,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const stickyHeaderIndexesRef = useRef(stickyHeaderIndexes)
stickyHeaderIndexesRef.current = stickyHeaderIndexes
const activeStickyHeaderIndexRef = useRef<number | null>(null)
const activeStickyHostIndexRef = useRef<number | null>(null)
const stickyRangeStartIndexRef = useRef(0)
const activeWorktreeRowIndex = useMemo(
() => renderRows.findIndex((row) => renderRowContainsWorktree(row, activeWorktreeId)),
@@ -1236,7 +1237,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
rangeExtractor: useCallback(
(range: Range) => {
stickyRangeStartIndexRef.current = range.startIndex
return extractWorktreeVirtualRowIndexes({ range, stickyHeaderIndexes })
return extractWorktreeVirtualRowIndexes({
range,
stickyHeaderIndexes,
rows: renderRowsRef.current
})
},
[stickyHeaderIndexes]
),
@@ -1456,13 +1461,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
)
const totalSize = virtualizer.getTotalSize()
const virtualItems = virtualizer.getVirtualItems()
const activeStickyHeaderIndex = getActiveStickyHeaderIndexForScroll({
const activeStickyIndexes = getActiveStickyIndexesForScroll({
rows: renderRows,
rangeStartIndex: stickyRangeStartIndexRef.current,
scrollOffset: virtualizer.scrollOffset ?? scrollOffsetRef.current,
stickyHeaderIndexes,
virtualItems
})
activeStickyHeaderIndexRef.current = activeStickyHeaderIndex
activeStickyHeaderIndexRef.current = activeStickyIndexes.groupIndex
activeStickyHostIndexRef.current = activeStickyIndexes.hostIndex
const measureMountedRows = useCallback(() => {
virtualizer.elementsCache.forEach((element) => {
@@ -2881,7 +2888,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
if (row.type === 'host-header') {
const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index
// Why: the host card is the outer hierarchy tier — it pins above
// group headers (z-30 vs z-20) and stays put while they hand off.
const isActiveStickyHost = activeStickyHostIndexRef.current === vItem.index
const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({
rows: renderRows,
index: vItem.index,
@@ -2894,18 +2903,18 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-sticky-header=""
data-worktree-sticky-header-active={isActiveStickyHeader ? '' : undefined}
data-worktree-sticky-header-active={isActiveStickyHost ? '' : undefined}
data-index={vItem.index}
ref={measureVirtualRowElement}
className={cn(
'left-0 right-0',
hasHeaderTopSpacing && !isActiveStickyHeader && 'pt-1',
isActiveStickyHeader
? 'sticky -top-px z-20 bg-worktree-sidebar'
hasHeaderTopSpacing && !isActiveStickyHost && 'pt-1',
isActiveStickyHost
? 'sticky -top-px z-30 bg-worktree-sidebar'
: 'absolute top-0'
)}
style={
isActiveStickyHeader
isActiveStickyHost
? undefined
: { transform: getVirtualRowTransform(vItem.start) }
}
@@ -2920,6 +2929,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (row.type === 'header') {
const isActiveStickyHeader = activeStickyHeaderIndexRef.current === vItem.index
// Why: when a host card is pinned, the group tier pins flush
// beneath it instead of at the viewport top.
const stickyTopClass =
activeStickyHostIndexRef.current !== null ? 'top-[35px]' : '-top-px'
const hasHeaderTopSpacing = shouldUseHeaderTopSpacing({
rows: renderRows,
index: vItem.index,
@@ -2979,7 +2992,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
// so the previous repo no longer stays pinned over it.
hasHeaderTopSpacing && !isActiveStickyHeader && 'pt-1',
isActiveStickyHeader
? 'sticky -top-px z-20 bg-worktree-sidebar'
? cn('sticky z-20 bg-worktree-sidebar', stickyTopClass)
: 'absolute top-0'
)}
style={
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import type { VirtualItem } from '@tanstack/react-virtual'
import {
HOST_STICKY_PINNED_HEIGHT,
extractWorktreeVirtualRowIndexes,
getActiveStickyIndexesForScroll,
getStickyHeaderIndexes,
type RenderRow
} from './worktree-list-virtual-rows'
function hostRow(hostId: string): RenderRow {
return {
type: 'host-header',
key: `host:${hostId}`,
hostId: hostId as never,
kind: 'ssh',
label: hostId,
detail: 'SSH',
health: 'available',
collapsed: false,
count: 1
}
}
function groupRow(key: string): RenderRow {
return { type: 'header', key, label: key, count: 1, tone: 'text-foreground' }
}
function itemStub(id: string): RenderRow {
return { type: 'item', key: id } as unknown as RenderRow
}
function virtualItem(index: number, start: number): VirtualItem {
return { index, start } as VirtualItem
}
// rows: [host-a, group-a1, item, item, host-b, group-b1, item]
const rows: RenderRow[] = [
hostRow('a'),
groupRow('a1'),
itemStub('wt-1'),
itemStub('wt-2'),
hostRow('b'),
groupRow('b1'),
itemStub('wt-3')
]
const stickyHeaderIndexes = getStickyHeaderIndexes(rows)
// Geometry: each row 100px tall for easy math.
const virtualItems = rows.map((_, index) => virtualItem(index, index * 100))
describe('getActiveStickyIndexesForScroll', () => {
it('pins the host and its inner group while scrolled inside a section', () => {
expect(
getActiveStickyIndexesForScroll({
rows,
rangeStartIndex: 2,
scrollOffset: 250,
stickyHeaderIndexes,
virtualItems
})
).toEqual({ hostIndex: 0, groupIndex: 1 })
})
it('hands the host tier off when the next host card reaches the top', () => {
expect(
getActiveStickyIndexesForScroll({
rows,
rangeStartIndex: 4,
scrollOffset: 400,
stickyHeaderIndexes,
virtualItems
})
).toMatchObject({ hostIndex: 4 })
})
it('never pins the previous host group beneath the next host card', () => {
const result = getActiveStickyIndexesForScroll({
rows,
rangeStartIndex: 4,
scrollOffset: 400,
stickyHeaderIndexes,
virtualItems
})
// group-a1 (index 1) must not survive into host b's tenure; group-b1 only
// pins once it reaches the slot beneath the pinned host card.
expect(result.groupIndex === 1).toBe(false)
})
it('offsets the group handoff by the pinned host height', () => {
// group-b1 starts at 500; with host pinned it should activate once
// scrollOffset + HOST_STICKY_PINNED_HEIGHT reaches 500.
const before = getActiveStickyIndexesForScroll({
rows,
rangeStartIndex: 5,
scrollOffset: 500 - HOST_STICKY_PINNED_HEIGHT - 1,
stickyHeaderIndexes,
virtualItems
})
const after = getActiveStickyIndexesForScroll({
rows,
rangeStartIndex: 5,
scrollOffset: 500 - HOST_STICKY_PINNED_HEIGHT,
stickyHeaderIndexes,
virtualItems
})
expect(before.groupIndex).not.toBe(5)
expect(after).toEqual({ hostIndex: 4, groupIndex: 5 })
})
it('degrades to single-tier rules when no host sections exist', () => {
const flatRows: RenderRow[] = [
groupRow('g1'),
itemStub('wt-1'),
groupRow('g2'),
itemStub('wt-2')
]
const flatSticky = getStickyHeaderIndexes(flatRows)
const flatItems = flatRows.map((_, index) => virtualItem(index, index * 100))
expect(
getActiveStickyIndexesForScroll({
rows: flatRows,
rangeStartIndex: 1,
scrollOffset: 150,
stickyHeaderIndexes: flatSticky,
virtualItems: flatItems
})
).toEqual({ hostIndex: null, groupIndex: 0 })
})
})
describe('extractWorktreeVirtualRowIndexes', () => {
it('keeps the pinned host mounted even when scrolled out of range', () => {
const indexes = extractWorktreeVirtualRowIndexes({
range: {
startIndex: 3,
endIndex: 3,
overscan: 0,
count: rows.length,
getItemIndex: (i: number) => i
} as never,
stickyHeaderIndexes,
rows
})
expect(indexes).toContain(0)
})
})
@@ -87,6 +87,87 @@ export function getStickyHeaderIndexes(rows: readonly RenderRow[]): number[] {
return indexes
}
// 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 type ActiveStickyIndexes = {
/** Pinned host card (tier 1), or null outside host sections. */
hostIndex: number | null
/** Pinned group header (tier 2), offset below the host when one is pinned. */
groupIndex: number | null
}
function getHostStickyIndexes(rows: readonly RenderRow[], sticky: readonly number[]): number[] {
return sticky.filter((index) => rows[index]?.type === 'host-header')
}
/** Two-tier sticky resolution: the host card is the outer hierarchy level so
* it stays pinned for the whole section while group headers hand off beneath
* it. Without host sections this degrades to the original single-tier rules. */
export function getActiveStickyIndexesForScroll(args: {
rows: readonly RenderRow[]
rangeStartIndex: number
scrollOffset: number
stickyHeaderIndexes: readonly number[]
virtualItems: readonly VirtualItem[]
}): ActiveStickyIndexes {
const hostIndexes = getHostStickyIndexes(args.rows, args.stickyHeaderIndexes)
const resolveWithHandoff = (
candidates: readonly number[],
pinnedOffset: number,
fallbackToCandidate: boolean
): number | null => {
const candidateIndex = getActiveStickyHeaderIndex(candidates, args.rangeStartIndex)
if (candidateIndex === null) {
return null
}
const candidate = args.virtualItems.find((item) => item.index === candidateIndex)
if (!candidate) {
return candidateIndex
}
// Why: hand off the moment the incoming header reaches its pinned slot
// (top of the viewport, or the bottom edge of the pinned host card).
if (args.scrollOffset + pinnedOffset >= candidate.start) {
return candidateIndex
}
const previous = getPreviousStickyHeaderIndex(candidates, candidateIndex)
if (previous !== null) {
return previous
}
// Why: a host section's first group is still in flow below the pinned
// host card until it reaches the slot — pinning it early would double
// it up. The host tier keeps the legacy fallback.
return fallbackToCandidate ? candidateIndex : null
}
const hostIndex = resolveWithHandoff(hostIndexes, 0, true)
const hostPosition = hostIndex === null ? -1 : hostIndexes.indexOf(hostIndex)
const nextHostIndex =
hostPosition >= 0 ? (hostIndexes[hostPosition + 1] ?? Number.POSITIVE_INFINITY) : null
const groupIndexes = args.stickyHeaderIndexes.filter((index) => {
if (args.rows[index]?.type !== 'header') {
return false
}
// Why: a group from the previous host must never pin beneath the next
// host's card — only groups inside the pinned host's section qualify.
if (hostIndex !== null) {
return index > hostIndex && index < (nextHostIndex ?? Number.POSITIVE_INFINITY)
}
return true
})
const groupIndex = resolveWithHandoff(
groupIndexes,
hostIndex !== null ? HOST_STICKY_PINNED_HEIGHT : 0,
hostIndex === null
)
return { hostIndex, groupIndex }
}
export function getActiveStickyHeaderIndex(
stickyHeaderIndexes: readonly number[],
rangeStartIndex: number
@@ -114,6 +195,7 @@ export function getPreviousStickyHeaderIndex(
export function extractWorktreeVirtualRowIndexes(args: {
range: Range
stickyHeaderIndexes: readonly number[]
rows?: readonly RenderRow[]
}): number[] {
const activeStickyHeaderIndex = getActiveStickyHeaderIndex(
args.stickyHeaderIndexes,
@@ -127,10 +209,15 @@ export function extractWorktreeVirtualRowIndexes(args: {
args.stickyHeaderIndexes,
activeStickyHeaderIndex
)
// Why: the pinned host card (tier 1) can be far above the visible range
// while group headers hand off beneath it — keep it mounted regardless.
const hostIndexes = args.rows ? getHostStickyIndexes(args.rows, args.stickyHeaderIndexes) : []
const activeHostIndex = getActiveStickyHeaderIndex(hostIndexes, args.range.startIndex)
return Array.from(
new Set([
activeStickyHeaderIndex,
...(previousStickyHeaderIndex === null ? [] : [previousStickyHeaderIndex]),
...(activeHostIndex === null ? [] : [activeHostIndex]),
...defaultRangeExtractor(args.range)
])
).sort((a, b) => a - b)