From 41b1866c62f446e6a56c7a92b2daa419ba72da55 Mon Sep 17 00:00:00 2001 From: Bet4 <0xbet4@gmail.com> Date: Fri, 5 Jun 2026 10:09:49 +0800 Subject: [PATCH] fix(app): keep library date headers sticky --- .../renderer/components/LibraryLanding.tsx | 3 + .../components/VirtualSessionList.tsx | 156 ++++++++++++++---- 2 files changed, 124 insertions(+), 35 deletions(-) diff --git a/packages/app/src/renderer/components/LibraryLanding.tsx b/packages/app/src/renderer/components/LibraryLanding.tsx index 89faee39..fbbacefb 100644 --- a/packages/app/src/renderer/components/LibraryLanding.tsx +++ b/packages/app/src/renderer/components/LibraryLanding.tsx @@ -210,6 +210,8 @@ export default function LibraryLanding({ onOpenSession, onCopySessionId, onShare label: bucket.label, testId: 'library-bucket-header', dataAttr: { 'data-bucket': bucket.key }, + sticky: true, + collapsible: false, }) for (const s of bucket.sessions) { out.push({ @@ -245,6 +247,7 @@ export default function LibraryLanding({ onOpenSession, onCopySessionId, onShare onCopySessionId={onCopySessionId} {...(onShare ? { onShare } : {})} testId="library-landing-scroll" + stickyHeaders /> )} diff --git a/packages/app/src/renderer/components/VirtualSessionList.tsx b/packages/app/src/renderer/components/VirtualSessionList.tsx index 194bbf3f..30fed91a 100644 --- a/packages/app/src/renderer/components/VirtualSessionList.tsx +++ b/packages/app/src/renderer/components/VirtualSessionList.tsx @@ -1,12 +1,12 @@ -import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react' +import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' +import { GroupedVirtuoso, Virtuoso } from 'react-virtuoso' import type { Session } from '@spool-lab/core' import SessionRow from './SessionRow.js' import type { BucketKey } from '../../shared/formatDate.js' export type SessionListRow = - | { kind: 'header'; id: string; label: ReactNode; testId?: string; dataAttr?: Record; collapsible?: boolean; defaultOpen?: boolean } + | { kind: 'header'; id: string; label: ReactNode; testId?: string; dataAttr?: Record; collapsible?: boolean; defaultOpen?: boolean; sticky?: boolean } | { kind: 'session'; id: string; session: Session; pinned?: boolean; showProject?: boolean; bucket?: BucketKey; headerId: string | null } | { kind: 'footer'; id: string; loading: boolean; exhausted: boolean; total: number } @@ -21,8 +21,22 @@ type Props = { testId?: string /** When true (default), bucket headers can collapse the rows beneath them. */ collapsibleSections?: boolean + /** Use react-virtuoso's grouped list so section headers stick at the top. */ + stickyHeaders?: boolean } +type StickyGroup = { + header: Extract + items: SessionListRow[] +} + +type StickySections = { + prelude: SessionListRow[] + groups: StickyGroup[] +} + +type StickyItemData = SessionListRow | null + /** * Virtualised list that flattens pinned/bucket/directory sections into one * scroll surface. Parents build the row list; this component only renders + @@ -37,8 +51,8 @@ export default function VirtualSessionList({ onShare, testId, collapsibleSections = true, + stickyHeaders = false, }: Props) { - const virtuosoRef = useRef(null) // Tracks which collapsible headers are explicitly closed. Headers not in // the set are open (we keep "closed" rather than "open" so newly arriving // headers default to open without needing to pre-populate state). @@ -61,9 +75,75 @@ export default function VirtualSessionList({ }) }, [rows, closed, collapsibleSections]) + const stickySections = useMemo(() => buildStickySections(visible), [visible]) + const stickyData = useMemo( + () => stickySections.groups.flatMap(group => [null, ...group.items]), + [stickySections], + ) + + const renderRow = useCallback((row: SessionListRow) => { + if (row.kind === 'header') { + const open = !closed.has(row.id) + return ( + toggleHeader(row.id) : null} + /> + ) + } + if (row.kind === 'footer') return