e822f6f58f
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
674 lines
23 KiB
Markdown
674 lines
23 KiB
Markdown
# 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 `<Virtuoso>` for `<MessageList>`, 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<MessageReaction[]>([]);` add:
|
|
```ts
|
|
const [readyKey, setReadyKey] = useState<string | null>(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<MessageListHandle, MessageListProps>(function MessageList(
|
|
{
|
|
rows,
|
|
renderRow,
|
|
computeKey,
|
|
initialAnchor,
|
|
ready,
|
|
estimateRowHeight = 64,
|
|
atBottomThreshold = 64,
|
|
onReachTop,
|
|
onAtBottomChange,
|
|
onTopRowChange,
|
|
},
|
|
ref,
|
|
) {
|
|
const scrollElRef = useRef<HTMLDivElement>(null);
|
|
const [revealed, setRevealed] = useState(false);
|
|
const atBottomRef = useRef(true);
|
|
// Load-older preservation: remember scrollHeight + first key across renders.
|
|
const prevFirstKeyRef = useRef<string | null>(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 (
|
|
<div
|
|
ref={scrollElRef}
|
|
onScroll={handleScroll}
|
|
className="h-full overflow-y-auto"
|
|
style={{ opacity: revealed ? 1 : 0, position: 'relative' }}
|
|
>
|
|
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
|
{items.map((vi) => (
|
|
<div
|
|
key={vi.key}
|
|
data-index={vi.index}
|
|
ref={virtualizer.measureElement}
|
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
|
>
|
|
{renderRow(vi.index, rows[vi.index]!)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* 12px bottom breathing space (matches the old Footer). */}
|
|
<div style={{ height: 12 }} />
|
|
</div>
|
|
);
|
|
});
|
|
```
|
|
> 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<VirtuosoHandle>(null);
|
|
```
|
|
to:
|
|
```ts
|
|
const listRef = useRef<MessageListHandle>(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 `<Virtuoso … />` element with:
|
|
```tsx
|
|
<MessageList
|
|
ref={listRef}
|
|
rows={virtuosoRows}
|
|
ready={listReady}
|
|
computeKey={(row) => 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 <Virtuoso>.
|
|
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 <specific behavior>"
|
|
```
|
|
|
|
---
|
|
|
|
## 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.
|