Docs.

Virtualizing the agent sessions rail

architecture/agent-sessions-virtualization.md

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:

Concern10k100kNotes
DB query~7 ms~tens of msNot 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 MBThe layout serializes both mine and shared to the client on every cold entry.
DOM nodes10k <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 searchfinelaggyThe 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-settle plays when it scrolls into view rather than on navigation. Acceptable — it's off-screen anyway.
  • Collapsed rail (CollapsibleRail) → navRef has ~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.