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

+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