Status: proposed (not yet implemented). This is the design for windowing the
session list in the agent rail so it stays fast at large session counts. See
office-assistant.md for the agent itself.
The current strategy: load all, render all
The rail lives in the (chat) layout (so it persists across session
navigations and is fetched once per entry into the agent section, not per
click). On mount it calls listAgentSessions(view, access) which returns the
viewer's own sessions (mine) and every shared one (shared) — no LIMIT —
ordered updated_at desc, then groupSessionsByAge buckets them
(today / yesterday / previous 7 days / …). SessionsMenu renders the active
tab's groups as plain DOM: a header <p> per group, a <SessionLink> per row.
This is the right call at realistic scale. Agent sessions are per-staffer chat transcripts; an account holding tens to low hundreds is normal, and "load all" keeps the code simple with no pagination state. The 10k fixture in the local seed is a deliberate stress test, well past realistic use.
Where it breaks at scale
Measured at the 10k stress fixture, and reasoned to 100k:
| Concern | 10k | 100k | Notes |
|---|---|---|---|
| DB query | ~7 ms | ~tens of ms | Not the bottleneck. Index-ordered scan (agent_sessions_account_recent_idx on (account_id, updated_at desc), partial (updated_at desc) where is_public). Cost is row volume, not the scan. |
| RSC payload | ~1.1 MB | ~10–20 MB | The layout serializes both mine and shared to the client on every cold entry. |
| DOM nodes | 10k <a> | 100k <a> | The hard wall. Un-windowed: seconds of initial render, sluggish scroll, real memory pressure. Breaks well before the others. Only the active tab is in the DOM, so this ≈ the larger single list. |
| Client search | fine | laggy | The filter runs in JS over the whole array per keystroke (O(n)). |
The DOM node count is the dominant problem; the payload is second; the DB is last to complain.
Why virtualization (vs pagination)
Virtualization renders only the rows in (and just around) the viewport, no
matter how many exist. For this list it wins over LIMIT + "load more":
- No extra network round-trips. Pagination adds a request per page; virtualization reuses the data already loaded. The payload is acceptable at realistic scale, so the goal is to fix rendering, not refetch.
- Kills the DOM wall and the render/scroll lag — the dominant costs — by keeping the live node count to ~the visible window (+overscan).
- No UX seams (no "load more" button, no scroll-to-fetch jank).
It does not shrink the payload (#2) or the per-keystroke filter (#3); those
stay O(n) but are cheap arithmetic/array work, not DOM. If the payload ever
became the limiter, add a LIMIT on top — the index already supports keyset
pagination on updated_at.
Dependency
Recommended: @tanstack/react-virtual
(@tanstack/react-virtual, ~one small package + @tanstack/virtual-core, no
install/build scripts, React 19 compatible). It tracks cumulative offsets
internally and supports dynamic row measurement, which this list needs
because group-header rows and session rows have different heights.
Alternative: hand-rolled, dependency-free. Viable only if every row is a
uniform fixed height (compute the visible range as floor(scrollTop / H),
no measurement). That means restyling group headers to the same height as
items and dropping the inter-group spacing — a denser look, and brittle if row
heights ever drift. Given the grouped, variable-height layout, the library is
the lower-risk choice; revisit if the dep is unwelcome.
Implementation
SessionsMenu already computes groups (the active tab) and filters them to
visible. The change is local to that component's render.
1. Memoize the filter and the flattened rows
The virtualizer re-renders on every scroll tick, so the O(n) filter and flatten must be memoized — otherwise scrolling 100k rows re-filters on every frame.
const groups = view === "private" ? mine : shared;
const visible = useMemo(() => {
if (!q) return groups;
return groups
.map((g) => ({
...g,
sessions: g.sessions.filter(
(s) =>
s.title.toLowerCase().includes(q) ||
(view === "public" && s.ownerName.toLowerCase().includes(q)),
),
}))
.filter((g) => g.sessions.length > 0);
}, [groups, q, view]);
2. Flatten the grouped list into a flat row array
A virtualizer indexes a flat space, so headers and items interleave into one list:
type Row =
| { kind: "header"; label: string }
| { kind: "session"; session: MenuSession };
const rows = useMemo<Row[]>(
() =>
visible.flatMap((g) => [
{ kind: "header", label: g.label } as const,
...g.sessions.map((s) => ({ kind: "session", session: s }) as const),
]),
[visible],
);
3. The virtualizer
The rail's <nav> is the scroll element (re-add a navRef). measureElement
reads each row's real height, so headers and items can differ — no hardcoded
sizes; estimateSize is just the first-paint guess.
const navRef = useRef<HTMLElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => navRef.current,
estimateSize: () => 28, // ~one session row; corrected by measureElement
overscan: 10,
getItemKey: (i) => {
const r = rows[i];
return r.kind === "header" ? `h:${r.label}` : r.session.id;
},
});
4. Render only the window
A spacer div holds the full scroll height; each visible row is absolutely
positioned at its measured offset and carries the measureElement ref.
<nav
ref={navRef}
aria-label="chat sessions"
className="mb-[23px] min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto border-b border-line"
>
<div style={{ height: virtualizer.getTotalSize(), position: "relative", width: "100%" }}>
{virtualizer.getVirtualItems().map((vi) => {
const row = rows[vi.index];
return (
<div
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${vi.start}px)` }}
>
{row.kind === "header" ? (
// pt-4 = the group gap; pb-1 = header→first-item gap
<p className="label !text-fg-subtle pt-4 pb-1">{row.label}</p>
) : (
// pb-0.5 = inter-item gap (was the list's space-y-0.5)
<div className="pb-0.5">
<SessionLink
session={row.session}
active={row.session.id === activeId}
view={view}
/>
</div>
)}
</div>
);
})}
</div>
</nav>
5. Spacing moves inside rows
Absolute-positioned rows get no inter-row margins, so the list's old
space-y-4 / space-y-0.5 / pt-3 become padding inside each row
(pt-4/pb-1 on headers, pb-0.5 on items). measureElement includes
padding in the measured height, so stacking stays exact and it looks the same.
The empty state (no visible rows) renders the existing <EmptyState> instead
of the virtualized list.
Edge cases
- Active row scrolled out of the window isn't in the DOM, so its one-shot
rainbow-settleplays when it scrolls into view rather than on navigation. Acceptable — it's off-screen anyway. - Collapsed rail (
CollapsibleRail) →navRefhas ~zero size → no rows rendered; fine. - Filtering shrinks
rows→ the virtualizer recomputes total size. - The pending shimmer / settle and the dividers are unchanged; only the list body is windowed.
What stays the same
The toggle + search header, both dividers (search-field border-b as the top
divider, the nav border-b + mb-[23px] bottom alignment to the chat
window), activeId from usePathname, and the full data load (no network
change — only the DOM is windowed).
Verification
tsc --noEmit+eslint.- In the browser against the 10k public list: count rendered
<a>nodes (should be ~the window size, not 10k), confirm smooth scroll, search still filters, the dividers/alignment hold, and the shimmer/settle still fire.