Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/app/src/renderer/components/LibraryLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -245,6 +247,7 @@ export default function LibraryLanding({ onOpenSession, onCopySessionId, onShare
onCopySessionId={onCopySessionId}
{...(onShare ? { onShare } : {})}
testId="library-landing-scroll"
stickyHeaders
/>
)}
</div>
Expand Down
156 changes: 121 additions & 35 deletions packages/app/src/renderer/components/VirtualSessionList.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>; collapsible?: boolean; defaultOpen?: boolean }
| { kind: 'header'; id: string; label: ReactNode; testId?: string; dataAttr?: Record<string, string>; 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 }

Expand All @@ -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<SessionListRow, { kind: 'header' }>
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 +
Expand All @@ -37,8 +51,8 @@ export default function VirtualSessionList({
onShare,
testId,
collapsibleSections = true,
stickyHeaders = false,
}: Props) {
const virtuosoRef = useRef<VirtuosoHandle | null>(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).
Expand All @@ -61,68 +75,140 @@ export default function VirtualSessionList({
})
}, [rows, closed, collapsibleSections])

const stickySections = useMemo(() => buildStickySections(visible), [visible])
const stickyData = useMemo<StickyItemData[]>(
() => stickySections.groups.flatMap(group => [null, ...group.items]),
[stickySections],
)

const renderRow = useCallback((row: SessionListRow) => {
if (row.kind === 'header') {
const open = !closed.has(row.id)
return (
<SectionHeader
row={row}
open={open}
onToggle={collapsibleSections && row.collapsible !== false ? () => toggleHeader(row.id) : null}
/>
)
}
if (row.kind === 'footer') return <Footer loading={row.loading} exhausted={row.exhausted} total={row.total} />
return (
<SessionRow
session={row.session}
{...(row.pinned ? { pinned: true } : {})}
{...(row.showProject ? { showProject: true } : {})}
{...(row.bucket ? { bucket: row.bucket } : {})}
{...(onPinChange ? { onPinChange } : {})}
onOpenSession={onOpenSession}
onCopySessionId={onCopySessionId}
{...(onShare ? { onShare } : {})}
/>
)
}, [closed, collapsibleSections, onCopySessionId, onOpenSession, onPinChange, onShare, toggleHeader])

if (stickyHeaders && stickySections.groups.length > 0) {
return (
<div className="relative flex-1 min-h-0">
<GroupedVirtuoso
data={stickyData}
groupCounts={stickySections.groups.map(group => group.items.length)}
groupContent={(groupIndex) => {
const row = stickySections.groups[groupIndex]!.header
const open = !closed.has(row.id)
return (
<SectionHeader
row={row}
open={open}
sticky
onToggle={collapsibleSections && row.collapsible !== false ? () => toggleHeader(row.id) : null}
/>
)
}}
computeItemKey={(index, row) => row?.id ?? `group-${index}`}
defaultItemHeight={64}
increaseViewportBy={400}
endReached={onEndReached}
data-testid={testId}
className="h-full [mask-image:linear-gradient(to_bottom,black_calc(100%_-_24px),transparent)]"
components={{
Header: () => stickySections.prelude.length > 0
? <>{stickySections.prelude.map(row => <div key={row.id}>{renderRow(row)}</div>)}</>
: null,
}}
itemContent={(_index, _groupIndex, row) => row ? renderRow(row) : null}
/>
</div>
)
}

return (
<Virtuoso
ref={virtuosoRef}
data={visible}
computeItemKey={(_index, row) => row.id}
defaultItemHeight={64}
increaseViewportBy={400}
endReached={onEndReached}
data-testid={testId}
className="flex-1 [mask-image:linear-gradient(to_bottom,black_calc(100%_-_24px),transparent)]"
itemContent={(_index, row) => {
if (row.kind === 'header') {
const open = !closed.has(row.id)
return (
<SectionHeader
row={row}
open={open}
onToggle={collapsibleSections && row.collapsible !== false ? () => toggleHeader(row.id) : null}
/>
)
}
if (row.kind === 'footer') return <Footer loading={row.loading} exhausted={row.exhausted} total={row.total} />
return (
<SessionRow
session={row.session}
{...(row.pinned ? { pinned: true } : {})}
{...(row.showProject ? { showProject: true } : {})}
{...(row.bucket ? { bucket: row.bucket } : {})}
{...(onPinChange ? { onPinChange } : {})}
onOpenSession={onOpenSession}
onCopySessionId={onCopySessionId}
{...(onShare ? { onShare } : {})}
/>
)
}}
itemContent={(_index, row) => renderRow(row)}
/>
)
}

function buildStickySections(rows: SessionListRow[]): StickySections {
const prelude: SessionListRow[] = []
const groups: StickyGroup[] = []
let current: StickyGroup | null = null
for (const row of rows) {
if (row.kind === 'header' && row.sticky) {
current = { header: row, items: [] }
groups.push(current)
continue
}
if (!current) {
prelude.push(row)
continue
}
current.items.push(row)
}
return { prelude, groups }
}

function SectionHeader({
row,
open,
onToggle,
sticky = false,
}: {
row: Extract<SessionListRow, { kind: 'header' }>
open: boolean
onToggle: (() => void) | null
sticky?: boolean
}) {
const headerClassName = sticky
? 'px-5 pt-3 pb-1 relative z-30 bg-warm-bg dark:bg-dark-bg border-b border-warm-border dark:border-dark-border'
: 'px-6 pt-3 pb-1'
const toggleClassName = sticky
? 'group flex max-w-full items-center gap-1.5 text-xs text-warm-faint dark:text-dark-muted hover:text-warm-text dark:hover:text-dark-text transition-colors duration-75 select-none'
: 'group w-full flex items-center gap-1.5 text-[10px] font-semibold tracking-[0.08em] text-warm-faint dark:text-dark-muted hover:text-warm-text dark:hover:text-dark-text transition-colors duration-75 select-none'
const labelClassName = sticky
? 'truncate text-xs text-warm-faint dark:text-dark-muted select-none'
: 'block text-[10px] font-semibold tracking-[0.08em] text-warm-faint dark:text-dark-muted select-none'
const content = (
<div
data-testid={row.testId}
{...(row.dataAttr ?? {})}
className="px-6 pt-3 pb-1"
className={headerClassName}
>
{onToggle ? (
<button
type="button"
onClick={onToggle}
aria-expanded={open}
className="group w-full flex items-center gap-1.5 text-[10px] font-semibold tracking-[0.08em] text-warm-faint dark:text-dark-muted hover:text-warm-text dark:hover:text-dark-text transition-colors duration-75 select-none"
className={toggleClassName}
>
<span>{row.label}</span>
<span className="truncate">{row.label}</span>
<svg
width="12"
height="12"
Expand All @@ -135,9 +221,9 @@ function SectionHeader({
</svg>
</button>
) : (
<span className="block text-[10px] font-semibold tracking-[0.08em] text-warm-faint dark:text-dark-muted select-none">
<div className={labelClassName}>
{row.label}
</span>
</div>
)}
</div>
)
Expand Down