# Message-List / Scroll Rewrite — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the `react-virtuoso` message list with a TanStack-Virtual list that opens/switches chats flicker-free (Discord-like), preserving every existing behavior. **Architecture:** Pure scroll-decision logic (`scrollController.ts`, unit-tested) + an isolated virtualization component (`MessageList.tsx`, TanStack Virtual, deferred reveal) + `ConversationPage` wiring. The flicker is killed by keeping the list hidden until messages+reactions+divider are stable, then anchoring before paint. **Tech Stack:** React 18, TypeScript, `@tanstack/react-virtual` (new), vitest, electron-vite. Spec: `docs/superpowers/specs/2026-06-02-message-list-scroll-rewrite-design.md` --- ## File Structure - Create: `apps/desktop/src/lib/scrollController.ts` — pure scroll math (no DOM/React). - Create: `apps/desktop/src/lib/scrollController.test.ts` — vitest unit tests. - Create: `apps/desktop/src/components/MessageList.tsx` — TanStack virtual list + reveal/stick/load-older. Exports `MessageList`, `MessageListHandle`, `VirtuosoRow` is imported from ConversationPage's shared type (moved in Task 5). - Modify: `apps/desktop/src/lib/useMessageReactions.ts` — add `ready` flag for the reveal gate. - Modify: `apps/desktop/src/pages/ConversationPage.tsx` — export the row type, swap `` for ``, drive the handle, pass `ready`. - Modify: `apps/desktop/package.json` — add `@tanstack/react-virtual`; remove `react-virtuoso` (Task 8). --- ## Task 1: Add the TanStack Virtual dependency **Files:** - Modify: `apps/desktop/package.json` - [ ] **Step 1: Install** Run (from repo root `chat-app/`): ```bash pnpm --filter @chat-app/desktop add @tanstack/react-virtual@^3.10.0 ``` Expected: adds `@tanstack/react-virtual` to `apps/desktop/package.json` dependencies; lockfile updated. - [ ] **Step 2: Verify it resolves** Run: `pnpm --filter @chat-app/desktop exec node -e "require.resolve('@tanstack/react-virtual'); console.log('ok')"` Expected: `ok` - [ ] **Step 3: Commit** ```bash git add apps/desktop/package.json pnpm-lock.yaml git commit -m "build(desktop): add @tanstack/react-virtual" ``` --- ## Task 2: Pure scroll-decision logic (TDD) **Files:** - Create: `apps/desktop/src/lib/scrollController.ts` - Test: `apps/desktop/src/lib/scrollController.test.ts` - [ ] **Step 1: Write the failing tests** ```ts // apps/desktop/src/lib/scrollController.test.ts import { describe, expect, it } from 'vitest'; import { isNearBottom, isNearTop, resolveInitialAnchor } from './scrollController'; const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({ scrollTop, scrollHeight, clientHeight, }); describe('isNearBottom', () => { it('true exactly at the bottom', () => { expect(isNearBottom(m(900, 1000, 100), 64)).toBe(true); }); it('true within threshold', () => { expect(isNearBottom(m(860, 1000, 100), 64)).toBe(true); }); it('false beyond threshold', () => { expect(isNearBottom(m(800, 1000, 100), 64)).toBe(false); }); }); describe('isNearTop', () => { it('true at top', () => { expect(isNearTop(m(0, 1000, 100), 64)).toBe(true); }); it('false past threshold', () => { expect(isNearTop(m(200, 1000, 100), 64)).toBe(false); }); }); describe('resolveInitialAnchor', () => { it('anchors to last row at end by default (no saved position)', () => { expect(resolveInitialAnchor(null, 50)).toEqual({ index: 49, align: 'end' }); }); it('anchors to bottom when saved position stuck to bottom', () => { expect(resolveInitialAnchor({ topmostIndex: 10, stickToBottom: true }, 50)).toEqual({ index: 49, align: 'end', }); }); it('restores the saved row at the top when scrolled up', () => { expect(resolveInitialAnchor({ topmostIndex: 12, stickToBottom: false }, 50)).toEqual({ index: 12, align: 'start', }); }); it('clamps a stale saved index to the current row count', () => { expect(resolveInitialAnchor({ topmostIndex: 999, stickToBottom: false }, 50)).toEqual({ index: 49, align: 'start', }); }); it('handles an empty list', () => { expect(resolveInitialAnchor(null, 0)).toEqual({ index: 0, align: 'end' }); }); }); ``` - [ ] **Step 2: Run, verify FAIL** Run: `pnpm --filter @chat-app/desktop exec vitest run src/lib/scrollController.test.ts` Expected: FAIL — "Failed to resolve import './scrollController'". - [ ] **Step 3: Implement** ```ts // apps/desktop/src/lib/scrollController.ts // Pure, DOM-free scroll-decision logic for MessageList. Unit-tested so the // tricky math is verified without a browser (jsdom has no layout). export interface ScrollMetrics { scrollTop: number; scrollHeight: number; clientHeight: number; } /** Distance from the bottom edge is within `threshold` px. */ export function isNearBottom(m: ScrollMetrics, threshold: number): boolean { return m.scrollHeight - (m.scrollTop + m.clientHeight) <= threshold; } /** Scroll offset is within `threshold` px of the top. */ export function isNearTop(m: ScrollMetrics, threshold: number): boolean { return m.scrollTop <= threshold; } export interface SavedPosition { topmostIndex: number; stickToBottom: boolean; } export interface Anchor { index: number; align: 'start' | 'end'; } /** * Where a freshly-opened chat should start. * - default / "left at bottom" → last row, aligned to the viewport bottom. * - "left scrolled up" → the saved top-most row, aligned to the viewport top * (clamped in case the cached row count shrank). */ export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: number): Anchor { if (rowCount <= 0) return { index: 0, align: 'end' }; if (saved && !saved.stickToBottom) { const index = Math.max(0, Math.min(saved.topmostIndex, rowCount - 1)); return { index, align: 'start' }; } return { index: rowCount - 1, align: 'end' }; } ``` - [ ] **Step 4: Run, verify PASS** Run: `pnpm --filter @chat-app/desktop exec vitest run src/lib/scrollController.test.ts` Expected: PASS (11 tests). - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/lib/scrollController.ts apps/desktop/src/lib/scrollController.test.ts git commit -m "feat(desktop): pure scroll-decision logic for new message list" ``` --- ## Task 3: Reveal-gate flag on `useMessageReactions` **Files:** - Modify: `apps/desktop/src/lib/useMessageReactions.ts` Reactions are the main post-paint height changer. The list reveal waits on their first fetch, so add a `ready` flag that is true once reactions for the current message-id set have been fetched (or there are no messages). - [ ] **Step 1: Add `ready` to the result type + state** In `UseMessageReactionsResult` add: ```ts ready: boolean; ``` After `const [rows, setRows] = useState([]);` add: ```ts const [readyKey, setReadyKey] = useState(null); ``` - [ ] **Step 2: Set the key after each fetch** Replace the `refresh` callback body so both branches stamp `readyKey`: ```ts const refresh = useCallback(async () => { if (messageIds.length === 0) { setRows([]); setReadyKey(idsKey); return; } try { const data = await listReactionsForMessages(supabase, messageIds); setRows(data); } catch (err: unknown) { console.error('listReactionsForMessages failed', err); } finally { setReadyKey(idsKey); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [idsKey]); ``` - [ ] **Step 3: Derive + return `ready`** Before the `return`: ```ts const ready = readyKey === idsKey; ``` And add `ready` to the returned object: ```ts return { byMessage, toggle, voteExclusive, ready }; ``` - [ ] **Step 4: Typecheck** Run: `pnpm --filter @chat-app/desktop typecheck` Expected: PASS (no output). - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/lib/useMessageReactions.ts git commit -m "feat(desktop): expose reactions reveal-gate flag (ready)" ``` --- ## Task 4: Export the shared row type from ConversationPage **Files:** - Modify: `apps/desktop/src/pages/ConversationPage.tsx` `MessageList` needs the row union. Export it from ConversationPage (smallest change; the type already lives there). - [ ] **Step 1: Export the type** Change the `type VirtuosoRow = …` declaration (near the top of the file) to: ```ts export type VirtuosoRow = | { kind: 'loader'; key: string } | { kind: 'message'; key: string; message: DecryptedMessage; idx: number } | { kind: 'pending'; key: string; item: OutboxItem }; ``` - [ ] **Step 2: Typecheck** Run: `pnpm --filter @chat-app/desktop typecheck` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add apps/desktop/src/pages/ConversationPage.tsx git commit -m "refactor(desktop): export VirtuosoRow type for MessageList" ``` --- ## Task 5: The `MessageList` component **Files:** - Create: `apps/desktop/src/components/MessageList.tsx` This is the integration unit. It is verified by typecheck here and **visually in dev** in Task 7 (jsdom can't layout-test it). The TanStack specifics (scrollToIndex timing, prepend offset) are the parts to refine during dev iteration. - [ ] **Step 1: Implement** ```tsx // apps/desktop/src/components/MessageList.tsx import { useVirtualizer } from '@tanstack/react-virtual'; import { forwardRef, useCallback as _unused, // placeholder removed below } from 'react'; ``` > NOTE for the implementer: write the file with the imports below (the line above is > illustrative only — do not keep it). Full file: ```tsx import { useVirtualizer } from '@tanstack/react-virtual'; import { forwardRef, useCallback, useImperativeHandle, useLayoutEffect, useRef, useState, type ReactNode, } from 'react'; import { isNearBottom, isNearTop, resolveInitialAnchor, type Anchor } from '../lib/scrollController'; import type { VirtuosoRow } from '../pages/ConversationPage'; export interface MessageListHandle { scrollToBottom(behavior?: ScrollBehavior): void; scrollToRow(index: number, align?: 'center' | 'end', behavior?: ScrollBehavior): void; } export interface MessageListProps { rows: VirtuosoRow[]; renderRow: (index: number, row: VirtuosoRow) => ReactNode; computeKey: (row: VirtuosoRow) => string; initialAnchor: { type: 'bottom' } | { type: 'row'; index: number }; /** Reveal gate — list stays hidden behind a spinner until true (no flicker). */ ready: boolean; estimateRowHeight?: number; atBottomThreshold?: number; onReachTop?: () => void; onAtBottomChange?: (atBottom: boolean) => void; onTopRowChange?: (topIndex: number) => void; } export const MessageList = forwardRef(function MessageList( { rows, renderRow, computeKey, initialAnchor, ready, estimateRowHeight = 64, atBottomThreshold = 64, onReachTop, onAtBottomChange, onTopRowChange, }, ref, ) { const scrollElRef = useRef(null); const [revealed, setRevealed] = useState(false); const atBottomRef = useRef(true); // Load-older preservation: remember scrollHeight + first key across renders. const prevFirstKeyRef = useRef(null); const prevScrollHeightRef = useRef(0); const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollElRef.current, estimateSize: () => estimateRowHeight, overscan: 8, getItemKey: (index) => computeKey(rows[index]!), }); const metrics = () => { const el = scrollElRef.current; return el ? { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight } : { scrollTop: 0, scrollHeight: 0, clientHeight: 0 }; }; const applyAnchor = useCallback( (anchor: Anchor) => { virtualizer.scrollToIndex(anchor.index, { align: anchor.align }); // Re-apply on the next frame: dynamic measurement settles after the first // paint, so a single scrollToIndex can land a few px off. The list is still // hidden here, so this correction is never visible. requestAnimationFrame(() => virtualizer.scrollToIndex(anchor.index, { align: anchor.align })); }, [virtualizer], ); // Deferred reveal: when ready, anchor (before paint) then reveal. useLayoutEffect(() => { if (!ready || revealed || rows.length === 0) return; const anchor: Anchor = initialAnchor.type === 'bottom' ? { index: rows.length - 1, align: 'end' } : { index: Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), align: 'start' }; applyAnchor(anchor); atBottomRef.current = initialAnchor.type === 'bottom'; onAtBottomChange?.(atBottomRef.current); requestAnimationFrame(() => setRevealed(true)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, rows.length]); // Stick-to-bottom: when content grows and we were at the bottom, re-pin. useLayoutEffect(() => { if (!revealed) return; if (atBottomRef.current) { virtualizer.scrollToIndex(rows.length - 1, { align: 'end' }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [rows.length, virtualizer.getTotalSize()]); // Load-older preservation: if rows were prepended (first key changed and count // grew), restore scrollTop by the height delta so the viewport stays put. useLayoutEffect(() => { const firstKey = rows.length > 0 ? computeKey(rows[0]!) : null; const el = scrollElRef.current; if (el && revealed && prevFirstKeyRef.current && firstKey !== prevFirstKeyRef.current) { const delta = el.scrollHeight - prevScrollHeightRef.current; if (delta > 0 && el.scrollTop < atBottomThreshold) { el.scrollTop += delta; } } prevFirstKeyRef.current = firstKey; prevScrollHeightRef.current = el?.scrollHeight ?? 0; // eslint-disable-next-line react-hooks/exhaustive-deps }, [rows]); const handleScroll = useCallback(() => { const m = metrics(); const atBottom = isNearBottom(m, atBottomThreshold); if (atBottom !== atBottomRef.current) { atBottomRef.current = atBottom; onAtBottomChange?.(atBottom); } if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.(); const first = virtualizer.getVirtualItems()[0]; if (first) onTopRowChange?.(first.index); // eslint-disable-next-line react-hooks/exhaustive-deps }, [atBottomThreshold, onAtBottomChange, onReachTop, onTopRowChange, virtualizer]); useImperativeHandle( ref, () => ({ scrollToBottom: () => { atBottomRef.current = true; virtualizer.scrollToIndex(rows.length - 1, { align: 'end' }); }, scrollToRow: (index, align = 'center') => { virtualizer.scrollToIndex(index, { align }); }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [virtualizer, rows.length], ); const items = virtualizer.getVirtualItems(); return (
{items.map((vi) => (
{renderRow(vi.index, rows[vi.index]!)}
))}
{/* 12px bottom breathing space (matches the old Footer). */}
); }); ``` > Implementer note: delete the illustrative first `import` snippet; keep only the full > file. The `requestAnimationFrame` timing in `applyAnchor`/reveal is the most likely > spot to refine during dev (Task 7). - [ ] **Step 2: Typecheck** Run: `pnpm --filter @chat-app/desktop typecheck` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add apps/desktop/src/components/MessageList.tsx git commit -m "feat(desktop): TanStack-Virtual MessageList with deferred reveal" ``` --- ## Task 6: Wire `MessageList` into `ConversationPage` **Files:** - Modify: `apps/desktop/src/pages/ConversationPage.tsx` - [ ] **Step 1: Imports + reveal gate** Replace the `react-virtuoso` import with: ```ts import { MessageList, type MessageListHandle } from '../components/MessageList'; ``` Capture the reactions `ready` flag — change the `useMessageReactions` destructure to also pull `ready`: ```ts const { byMessage: reactionsByMessage, toggle: toggleReaction, voteExclusive: votePoll, ready: reactionsReady, } = useMessageReactions(messageIds, session?.user.id); ``` Add a reveal gate with a 300ms max-timeout fallback (so empty/slow reactions never hang): ```ts const [revealTimedOut, setRevealTimedOut] = useState(false); useEffect(() => { if (!id || loading || messages.length === 0) return; const t = window.setTimeout(() => setRevealTimedOut(true), 300); return () => window.clearTimeout(t); }, [id, loading, messages.length]); const listReady = !loading && messages.length > 0 && (reactionsReady || revealTimedOut); ``` - [ ] **Step 2: Replace the `virtuosoRef` type + handle** Change: ```ts const virtuosoRef = useRef(null); ``` to: ```ts const listRef = useRef(null); ``` Replace every `virtuosoRef.current?.scrollToIndex({ index: 'LAST', align: 'end', behavior })` call (in `jumpToBottom`, the pending-snap effect, `snapToBottom`) with: ```ts listRef.current?.scrollToBottom('auto'); ``` Replace the `jumpToMessage` scroll (`virtuosoRef.current?.scrollToIndex({ index: rowIndex, align: 'center', behavior: 'smooth' })`) with: ```ts listRef.current?.scrollToRow(rowIndex, 'center', 'smooth'); ``` - [ ] **Step 3: Compute `initialAnchor`** Replace the `initialTopMostIndex` `useMemo` (the `IndexLocationWithAlign` one from the earlier hotfix) with: ```ts const initialAnchor = useMemo<{ type: 'bottom' } | { type: 'row'; index: number }>(() => { const saved = savedPositionRef.current; if (saved && !saved.stickToBottom) return { type: 'row', index: saved.topmostIndex }; return { type: 'bottom' }; }, []); ``` Remove the now-unused `IndexLocationWithAlign` import. - [ ] **Step 4: Swap the JSX** Replace the entire `` element with: ```tsx row.key} initialAnchor={initialAnchor} atBottomThreshold={250} onReachTop={handleStartReached} onAtBottomChange={handleAtBottomStateChange} onTopRowChange={(topIndex) => handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })} renderRow={(_index, row) => { // ...exact same body the old `itemContent` had (loader / pending / // message branches) — move it verbatim from the deleted . return renderConversationRow(row); }} /> ``` Move the old `itemContent` body into a local `renderConversationRow(row)` helper (or inline it) so the message/loader/pending branches are unchanged. `handleRangeChanged` already accepts `{ startIndex, endIndex }`. - [ ] **Step 5: Typecheck + unit tests** Run: `pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/desktop test` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add apps/desktop/src/pages/ConversationPage.tsx git commit -m "feat(desktop): use MessageList in ConversationPage (replace react-virtuoso)" ``` --- ## Task 7: Dev verification (with the user) — iterate until smooth **Files:** none (runtime verification) - [ ] **Step 1: Run the dev build** User runs (in `chat-app/`): `pnpm desktop:dev` - [ ] **Step 2: Verify behaviors live** Switch between several chats repeatedly and confirm, using the `SCROLL_DEBUG` console output where helpful: - No jump and no multi-flicker on chat switch (opens cleanly at the bottom / saved row). - New message while at bottom auto-scrolls; while scrolled up shows the pill. - Unread divider present without a later shift. - Scroll to top loads older without the viewport jumping. - Jump-to-message (reply tap / pinned / search) scrolls to the target. - Sent/pending message snaps to bottom. - [ ] **Step 3: Refine** If any behavior is off, adjust `MessageList.tsx` (most likely the `applyAnchor`/reveal `requestAnimationFrame` timing or the stick-to-bottom effect) and re-verify. Commit each refinement: ```bash git commit -am "fix(desktop): refine MessageList " ``` --- ## Task 8: Cleanup + release 0.21.6 **Files:** - Modify: `apps/desktop/src/pages/ConversationPage.tsx` (remove instrumentation) - Modify: `apps/desktop/package.json` (remove `react-virtuoso`) - [ ] **Step 1: Remove the `SCROLL_DEBUG` instrumentation** Delete the `SCROLL_DEBUG`/`dbgNow`/`dbgLog` block, the render-logger `useEffect`, and the `dbgLog(...)` calls inside `handleRangeChanged` / `handleAtBottomStateChange`. - [ ] **Step 2: Remove the old dependency** Run: `pnpm --filter @chat-app/desktop remove react-virtuoso` Then confirm no references remain: Run: `grep -rn "react-virtuoso\|Virtuoso\b" apps/desktop/src || echo "clean"` Expected: `clean`. - [ ] **Step 3: Typecheck + tests + commit** Run: `pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/desktop test` Expected: PASS. ```bash git add -A git commit -m "chore(desktop): drop react-virtuoso + scroll debug instrumentation" ``` - [ ] **Step 4: Release** Run (from `chat-app/`, tree clean): `node scripts/release.mjs 0.21.6 "- Nachrichtenliste komplett überarbeitet: Chat-Wechsel öffnet jetzt ruckel- und flackerfrei direkt unten\n- Älteren Verlauf laden springt nicht mehr"` Then verify `latest.yml` shows 0.21.6 on `update.netralax.de` **and** `update.netralax.cloud`. --- ## Self-Review - **Spec coverage:** deferred reveal (Tasks 3,5,6) ✓; TanStack virtualization (Tasks 1,5) ✓; isolation into MessageList + scrollController (Tasks 2,5) ✓; stick-to-bottom (Task 5) ✓; load-older preservation (Task 5) ✓; preserved behaviors incl. jump-to-message/pill/divider/pending (Task 6) ✓; pure-logic unit tests (Task 2) ✓; dev verification (Task 7) ✓; cleanup + release (Task 8) ✓. - **Placeholders:** the only prose-only steps are the deliberately runtime Task 7 (no code possible) and the "move itemContent verbatim" in Task 6 Step 4 (the body is large and unchanged — copying it verbatim, not rewriting). The illustrative throwaway import in Task 5 Step 1 is explicitly flagged for deletion. - **Type consistency:** `MessageListHandle.scrollToBottom/scrollToRow`, `VirtuosoRow`, `Anchor`, `ScrollMetrics`, `resolveInitialAnchor` signatures are consistent across Tasks 2/5/6. `ready` flag added in Task 3 is consumed in Task 6.