From ea3462db2123b381da1eb702d265ea0e52b934a5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:09:37 -0700 Subject: [PATCH] Implement sticky group headers in activity thread list Keep group headers visible at the top while scrolling when threads are grouped. Headers stick to the viewport while their section is in view, then unstick as the next header approaches. --- .../activity/activity-thread-list-pane.tsx | 70 +++++++++++++++---- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/activity/activity-thread-list-pane.tsx b/src/renderer/src/components/activity/activity-thread-list-pane.tsx index cb9d9f9f7c8..ceef01f253d 100644 --- a/src/renderer/src/components/activity/activity-thread-list-pane.tsx +++ b/src/renderer/src/components/activity/activity-thread-list-pane.tsx @@ -9,12 +9,18 @@ import { import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { ActivityThreadListToolbar } from './activity-thread-list-toolbar' +import { + getActiveStickyHeaderIndex, + getActiveStickyHeaderIndexForScroll, + getPreviousStickyHeaderIndex +} from '../sidebar/worktree-list/viewport/virtual-rows' import { ActivityThreadVirtualRow } from './activity-thread-virtual-row' import { ActivityThreadListResizeHandle } from './activity-thread-list-resize-handle' import { buildActivityVirtualItems, estimateActivityVirtualItemSize, findActivityThreadItemIndex, + getActivityHeaderItemIndexes, getActivityVirtualItemKey } from './activity-thread-virtual-items' import type { @@ -147,6 +153,10 @@ export function ActivityThreadListPane({ }), [visibleThreadGroups, groupBy, effectiveCollapsedGroupKeys] ) + const headerItemIndexes = useMemo( + () => getActivityHeaderItemIndexes(virtualItems), + [virtualItems] + ) const selectedItemIndex = useMemo( () => findActivityThreadItemIndex(virtualItems, selectedPaneKey), [virtualItems, selectedPaneKey] @@ -173,24 +183,44 @@ export function ActivityThreadListPane({ }, rangeExtractor: useCallback( (range: Range) => { - const indexes = defaultRangeExtractor(range) - if ( - selectedItemIndex !== null && - selectedItemIndex >= 0 && - !indexes.includes(selectedItemIndex) - ) { - indexes.push(selectedItemIndex) - indexes.sort((a, b) => a - b) + const activeStickyIndex = + groupBy !== 'none' + ? getActiveStickyHeaderIndex(headerItemIndexes, range.startIndex) + : null + const previousStickyIndex = + activeStickyIndex !== null + ? getPreviousStickyHeaderIndex(headerItemIndexes, activeStickyIndex) + : null + const indexSet = new Set(defaultRangeExtractor(range)) + if (activeStickyIndex !== null) { + indexSet.add(activeStickyIndex) } - return indexes + if (previousStickyIndex !== null) { + indexSet.add(previousStickyIndex) + } + if (selectedItemIndex !== null && selectedItemIndex >= 0) { + indexSet.add(selectedItemIndex) + } + return Array.from(indexSet).sort((a, b) => a - b) }, - [selectedItemIndex] + [groupBy, headerItemIndexes, selectedItemIndex] ), overscan: 8, observeElementRect: observeActivityListRect, useFlushSync: false }) + const scrollOffset = virtualizer.scrollOffset ?? 0 + const activeStickyHeaderIndex = + groupBy !== 'none' + ? getActiveStickyHeaderIndexForScroll({ + rangeStartIndex: virtualizer.range?.startIndex ?? 0, + scrollOffset, + stickyHeaderIndexes: headerItemIndexes, + virtualItems: virtualizer.getVirtualItems() + }) + : null + const resizable = onResizeStart !== undefined return (