mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
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:
1 parent
26f2993257
commit
001c19d4bd
4 files changed
+237
-8
No files matched your search
+3
-2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user