PAP-2645: Virtualize long issue chat threads

Add @tanstack/react-virtual and gate IssueChatThread on a 150-row
threshold. Thread shorter than the threshold still uses the direct
render path; longer threads window the merged feed via
useWindowVirtualizer with dynamic measurement and overscan, so the
document scroll continues to represent the full conversation height
without introducing a nested scrollbar.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 16:48:58 -05:00
1 parent 26f2993257
commit 001c19d4bd
4 files changed
+237 -8

No files matched your search

+23 -3
View File
@@ -30,7 +30,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
cli:
dependencies:
@@ -674,6 +674,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@19.2.4)
'@tanstack/react-virtual':
specifier: ^3.13.0
version: 3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -755,7 +758,7 @@ importers:
version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)
vitest:
specifier: ^3.0.5
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0)
packages:
@@ -3492,6 +3495,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.13.24':
resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.14.0':
resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -9758,6 +9770,14 @@ snapshots:
'@tanstack/query-core': 5.90.20
react: 19.2.4
'@tanstack/react-virtual@3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/virtual-core': 3.14.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/virtual-core@3.14.0': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
@@ -13127,7 +13147,7 @@ snapshots:
- tsx
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
+3 -2
View File
@@ -46,6 +46,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-virtual": "^3.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -62,17 +63,17 @@
"tailwind-merge": "^3.4.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.7",
"@storybook/addon-a11y": "10.3.5",
"@storybook/addon-docs": "10.3.5",
"@storybook/react-vite": "10.3.5",
"@tailwindcss/vite": "^4.0.7",
"@types/node": "^25.2.3",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"storybook": "10.3.5",
"tailwindcss": "^4.0.7",
"typescript": "^5.7.3",
"storybook": "10.3.5",
"vite": "^6.1.0",
"vitest": "^3.0.5"
}
+101 -3
View File
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent } from "@paperclipai/shared";
import {
IssueChatThread,
VIRTUALIZED_THREAD_ROW_THRESHOLD,
canStopIssueChatRun,
resolveAssistantMessageFoldedState,
resolveIssueChatHumanAuthor,
@@ -330,7 +331,94 @@ describe("IssueChatThread", () => {
});
});
it("mounts one measurable row per merged long-thread message", () => {
it("virtualizes long merged threads so only a windowed slice mounts", () => {
const root = createRoot(container);
const totalMergedRows =
issueChatLongThreadComments.length
+ issueChatLongThreadEvents.length
+ issueChatLongThreadLinkedRuns.length;
expect(totalMergedRows).toBeGreaterThanOrEqual(VIRTUALIZED_THREAD_ROW_THRESHOLD);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={issueChatLongThreadComments}
linkedRuns={issueChatLongThreadLinkedRuns}
timelineEvents={issueChatLongThreadEvents}
liveRuns={[]}
agentMap={issueChatLongThreadAgentMap}
currentUserId="user-board"
onAdd={async () => {}}
showComposer={false}
showJumpToLatest={false}
enableLiveTranscriptPolling={false}
transcriptsByRunId={issueChatLongThreadTranscriptsByRunId}
hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)}
/>
</MemoryRouter>,
);
});
const virtualizer = container.querySelector(
'[data-testid="issue-chat-thread-virtualizer"]',
) as HTMLDivElement | null;
expect(virtualizer).not.toBeNull();
expect(virtualizer?.dataset.virtualCount).toBe(String(totalMergedRows));
const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]');
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThan(totalMergedRows);
const virtualRows = container.querySelectorAll(
'[data-testid="issue-chat-thread-virtual-row"]',
);
expect(virtualRows.length).toBe(rows.length);
for (const row of Array.from(virtualRows)) {
const transform = (row as HTMLDivElement).style.transform;
expect(transform).toMatch(/translateY\(/);
}
act(() => {
root.unmount();
});
});
it("keeps the direct render path for short threads under the virtualization threshold", () => {
const root = createRoot(container);
const directComments = issueChatLongThreadComments.slice(0, 12);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={directComments}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
agentMap={issueChatLongThreadAgentMap}
currentUserId="user-board"
onAdd={async () => {}}
showComposer={false}
showJumpToLatest={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(
container.querySelector('[data-testid="issue-chat-thread-virtualizer"]'),
).toBeNull();
const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]');
expect(rows.length).toBe(directComments.length);
act(() => {
root.unmount();
});
});
it("renders virtualized rows with the same role/kind metadata as the direct path", () => {
const root = createRoot(container);
act(() => {
@@ -355,8 +443,18 @@ describe("IssueChatThread", () => {
});
const rows = container.querySelectorAll('[data-testid="issue-chat-message-row"]');
expect(rows.length).toBeGreaterThanOrEqual(450);
expect(container.querySelectorAll('[data-message-role="assistant"]').length).toBeGreaterThanOrEqual(150);
expect(rows.length).toBeGreaterThan(0);
const roles = new Set<string>();
const kinds = new Set<string>();
for (const row of Array.from(rows)) {
const element = row as HTMLDivElement;
const role = element.dataset.messageRole;
const kind = element.dataset.messageKind;
if (role) roles.add(role);
if (kind) kinds.add(kind);
}
expect(roles.size).toBeGreaterThan(0);
expect(kinds.size).toBeGreaterThan(0);
act(() => {
root.unmount();
+110
View File
@@ -27,6 +27,7 @@ import {
type ReactNode,
} from "react";
import { Link, useLocation } from "@/lib/router";
import { useWindowVirtualizer } from "@tanstack/react-virtual";
import type {
Agent,
FeedbackDataSharingPreference,
@@ -2097,6 +2098,106 @@ function issueChatMessageQueuedRunIsInterrupting(
return Boolean(queueTargetRunId && interruptingQueuedRunId === queueTargetRunId);
}
// Above ~150 merged rows the direct render path forces React to mount and
// re-render hundreds of Markdown bodies, feedback controls, and avatars on
// unrelated parent updates. Above this threshold we switch to a windowed
// render path so only visible rows plus overscan stay mounted.
export const VIRTUALIZED_THREAD_ROW_THRESHOLD = 150;
const VIRTUALIZED_THREAD_OVERSCAN = 6;
// Rough "average row" estimate. The virtualizer measures real heights as
// rows mount, so this only affects offscreen rows it has not seen yet.
const VIRTUALIZED_THREAD_ROW_ESTIMATE_PX = 220;
const VIRTUALIZED_THREAD_GAP_FULL_PX = 16;
const VIRTUALIZED_THREAD_GAP_EMBEDDED_PX = 12;
interface VirtualizedIssueChatThreadListProps {
messages: readonly ThreadMessage[];
feedbackVoteByTargetId: ReadonlyMap<string, FeedbackVoteValue>;
activeRunIds: ReadonlySet<string>;
stoppingRunId?: string | null;
interruptingQueuedRunId?: string | null;
variant: "full" | "embedded";
}
function VirtualizedIssueChatThreadList({
messages,
feedbackVoteByTargetId,
activeRunIds,
stoppingRunId,
interruptingQueuedRunId,
variant,
}: VirtualizedIssueChatThreadListProps) {
const parentRef = useRef<HTMLDivElement | null>(null);
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const element = parentRef.current;
if (!element || typeof window === "undefined") return;
const update = () => {
const offset = element.getBoundingClientRect().top + window.scrollY;
setScrollMargin((previous) => (Math.abs(previous - offset) < 0.5 ? previous : offset));
};
update();
window.addEventListener("resize", update);
return () => {
window.removeEventListener("resize", update);
};
}, []);
const gap = variant === "embedded"
? VIRTUALIZED_THREAD_GAP_EMBEDDED_PX
: VIRTUALIZED_THREAD_GAP_FULL_PX;
const virtualizer = useWindowVirtualizer({
count: messages.length,
estimateSize: () => VIRTUALIZED_THREAD_ROW_ESTIMATE_PX,
overscan: VIRTUALIZED_THREAD_OVERSCAN,
scrollMargin,
gap,
getItemKey: (index) => messages[index]?.id ?? index,
});
const virtualItems = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
return (
<div
ref={parentRef}
data-testid="issue-chat-thread-virtualizer"
data-virtual-count={messages.length}
style={{ position: "relative", width: "100%", height: totalSize }}
>
{virtualItems.map((virtualItem) => {
const message = messages[virtualItem.index];
if (!message) return null;
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
data-testid="issue-chat-thread-virtual-row"
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
transform: `translateY(${virtualItem.start - scrollMargin}px)`,
}}
>
<IssueChatMessageRow
message={message}
feedbackVoteByTargetId={feedbackVoteByTargetId}
activeRunIds={activeRunIds}
stoppingRunId={stoppingRunId}
interruptingQueuedRunId={interruptingQueuedRunId}
/>
</div>
);
})}
</div>
);
}
interface IssueChatMessageRowProps {
message: ThreadMessage;
feedbackVoteByTargetId: ReadonlyMap<string, FeedbackVoteValue>;
@@ -2933,6 +3034,15 @@ export function IssueChatThread({
)}>
{resolvedEmptyMessage}
</div>
) : messages.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD ? (
<VirtualizedIssueChatThreadList
messages={messages}
feedbackVoteByTargetId={feedbackVoteByTargetId}
activeRunIds={activeRunIds}
stoppingRunId={stoppingRunId}
interruptingQueuedRunId={interruptingQueuedRunId}
variant={variant}
/>
) : (
// Keep transcript rendering independent from assistant-ui's
// index-scoped message providers; live transcripts can shrink