mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 00:03:09 +00:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user