diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5db36a22b47..7aa8a189cf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/ui/package.json b/ui/package.json index dda6e7154b6..7a2b4914987 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" } diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 96e3fd70173..f3d89d1ae90 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -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( + + {}} + showComposer={false} + showJumpToLatest={false} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueChatLongThreadTranscriptsByRunId} + hasOutputForRun={(runId) => issueChatLongThreadTranscriptsByRunId.has(runId)} + /> + , + ); + }); + + 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( + + {}} + showComposer={false} + showJumpToLatest={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + 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(); + const kinds = new Set(); + 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(); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index ec47106a228..6c0d4761fe5 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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; + activeRunIds: ReadonlySet; + stoppingRunId?: string | null; + interruptingQueuedRunId?: string | null; + variant: "full" | "embedded"; +} + +function VirtualizedIssueChatThreadList({ + messages, + feedbackVoteByTargetId, + activeRunIds, + stoppingRunId, + interruptingQueuedRunId, + variant, +}: VirtualizedIssueChatThreadListProps) { + const parentRef = useRef(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 ( +
+ {virtualItems.map((virtualItem) => { + const message = messages[virtualItem.index]; + if (!message) return null; + return ( +
+ +
+ ); + })} +
+ ); +} + interface IssueChatMessageRowProps { message: ThreadMessage; feedbackVoteByTargetId: ReadonlyMap; @@ -2933,6 +3034,15 @@ export function IssueChatThread({ )}> {resolvedEmptyMessage} + ) : messages.length >= VIRTUALIZED_THREAD_ROW_THRESHOLD ? ( + ) : ( // Keep transcript rendering independent from assistant-ui's // index-scoped message providers; live transcripts can shrink