diff --git a/tests/perf/task-chat/README.md b/tests/perf/task-chat/README.md
new file mode 100644
index 0000000000..b5de1eb999
--- /dev/null
+++ b/tests/perf/task-chat/README.md
@@ -0,0 +1,44 @@
+# Task chat scrollback regression
+
+Run from the repository root:
+
+```sh
+pnpm exec playwright test --config tests/perf/task-chat/playwright.config.ts
+```
+
+The test starts an isolated Vite server on port 4197. It needs no database,
+credentials, or running agents. It renders the real `TaskChatThreadView`, bubbles,
+markdown, run folds, and scroller with 200 long responses and 4,000 historical
+tool entries. A synthetic live tail updates ten times per second. A second case
+recreates the history objects on each update, as transcript projection can do.
+The harness uses a plain reply textarea; it does not test the full task composer
+or server-to-browser transport.
+
+Chromium samples main-thread task time for three seconds. Both cases must stay
+below 50% main-thread utilization and deliver at least 20 updates. The ceiling
+leaves room for machine variation while detecting the original saturation.
+Performance JSON is attached to each test result. The test also checks reading
+position during updates, typing, return to latest, lazy tool inspection, and
+preserved tool expansion across closing/reopening a run.
+
+For manual inspection, start the same server and open
+`http://127.0.0.1:4197/tests/task-chat-perf.html`:
+
+```sh
+pnpm --filter @paperclipai/ui exec vite --host 127.0.0.1 --port 4197 --strictPort
+```
+
+This HTML entry is not part of the shipped application build.
+
+## Reproduction and results
+
+On macOS Chromium against commit `a05b828bc`, the original code consumed 97.8%
+of the main thread and delivered 8 updates. With the fix, the same dev fixture
+consumed 11.6% for tail-only updates and 31.5% for recreated history, delivering
+32 updates in both cases. These are local samples, not cross-machine promises.
+
+The normal Vitest suite includes deterministic guards in
+`TaskChatThreadView.performance.test.tsx` and `TaskChatTurn.test.tsx`: unchanged
+history does not rerender or reparse markdown, changed content/gallery callbacks
+remain current, and collapsed tools mount on demand. These run in ordinary CI;
+the browser performance test is opt-in.
diff --git a/tests/perf/task-chat/playwright.config.ts b/tests/perf/task-chat/playwright.config.ts
new file mode 100644
index 0000000000..563486b1b8
--- /dev/null
+++ b/tests/perf/task-chat/playwright.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: ".",
+ testMatch: "*.spec.ts",
+ workers: 1,
+ timeout: 120_000,
+ use: { baseURL: "http://127.0.0.1:4197", viewport: { width: 1440, height: 900 }, trace: "retain-on-failure" },
+ webServer: {
+ command: "pnpm --filter @paperclipai/ui exec vite --host 127.0.0.1 --port 4197 --strictPort",
+ url: "http://127.0.0.1:4197/tests/task-chat-perf.html",
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+});
diff --git a/tests/perf/task-chat/scrollback.spec.ts b/tests/perf/task-chat/scrollback.spec.ts
new file mode 100644
index 0000000000..6e0dec9fea
--- /dev/null
+++ b/tests/perf/task-chat/scrollback.spec.ts
@@ -0,0 +1,56 @@
+import { expect, test } from "@playwright/test";
+
+for (const reproject of [false, true]) {
+test(`long scrollback stays responsive (${reproject ? "reprojected history" : "tail only"})`, async ({ page }, testInfo) => {
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto("/tests/task-chat-perf.html");
+ await expect(page.getByTestId("task-chat-scroller")).toBeVisible({ timeout: 90_000 });
+ await expect(page.locator('[data-thread-anchor^="history-"]')).toHaveCount(200);
+ if (reproject) await page.getByLabel("Recreate history objects").check();
+ const session = await page.context().newCDPSession(page);
+ await session.send("Performance.enable");
+ const read = async () => Object.fromEntries((await session.send("Performance.getMetrics")).metrics.map(({ name, value }) => [name, value]));
+ await page.getByRole("button", { name: "Start streaming" }).click();
+ const before = await read();
+ await page.waitForTimeout(3000);
+ const after = await read();
+ const metrics = {
+ mainThreadBusyPercent: 100 * (after.TaskDuration - before.TaskDuration) / (after.Timestamp - before.Timestamp),
+ scriptMs: 1000 * (after.ScriptDuration - before.ScriptDuration),
+ layoutMs: 1000 * (after.LayoutDuration - before.LayoutDuration),
+ domNodes: await page.locator("*").count(),
+ ticks: Number(await page.getByTestId("stream-tick").textContent()),
+ };
+ console.log(JSON.stringify(metrics));
+ await testInfo.attach("performance.json", { body: JSON.stringify(metrics, null, 2), contentType: "application/json" });
+ // Read scrollback without getting pulled down by ongoing live updates.
+ const scroller = page.getByTestId("task-chat-scroller");
+ await scroller.hover();
+ await page.mouse.wheel(0, -700);
+ await expect(page.getByRole("button", { name: "Scroll to latest" })).toBeVisible();
+ const top = await scroller.evaluate((element) => element.scrollTop);
+ await page.getByRole("textbox", { name: "Reply" }).fill("Reply remains usable during streaming.");
+ await page.waitForTimeout(300);
+ expect(await scroller.evaluate((element) => element.scrollTop)).toBeCloseTo(top, 0);
+ await expect(page.getByRole("textbox", { name: "Reply" })).toHaveValue("Reply remains usable during streaming.");
+ await page.getByRole("button", { name: "Scroll to latest" }).click();
+ await expect.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
+ await page.getByRole("button", { name: "Stop streaming" }).click();
+ await expect(page.getByTestId("task-chat-tool-card")).toHaveCount(0);
+ const summary = page.getByTestId("task-chat-turn-summary").last();
+ await summary.click();
+ await expect(summary).toHaveAttribute("aria-expanded", "true");
+ await expect(page.getByTestId("task-chat-tool-card")).toHaveCount(20);
+ const tool = page.getByTestId("task-chat-tool-card").first();
+ await tool.getByRole("button").click();
+ await expect(tool).toContainText("File inspected successfully.");
+ await summary.click();
+ await summary.click();
+ await expect(tool).toContainText("File inspected successfully.");
+ expect(errors).toEqual([]);
+ // A broad regression ceiling, not a machine-specific benchmark target.
+ expect(metrics.mainThreadBusyPercent).toBeLessThan(50);
+ expect(metrics.ticks).toBeGreaterThanOrEqual(20);
+});
+}
diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx
index 1fff76d2db..b1e5bb8fda 100644
--- a/ui/src/components/TaskChatThread.tsx
+++ b/ui/src/components/TaskChatThread.tsx
@@ -2454,6 +2454,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
],
);
+ const renderBrief = useCallback(
+ () => issueBrief ? : null,
+ [issueBrief],
+ );
+
const assignedAgentForNotice = useMemo(() => {
if (!currentAssigneeValue?.startsWith("agent:")) return null;
const assigneeAgentId = currentAssigneeValue.slice("agent:".length);
@@ -2707,11 +2712,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
attachments={attachments}
header={threadHeaderWithBlockers}
renderInteraction={renderInteraction}
- renderBrief={
- issueBrief
- ? () =>
- : undefined
- }
+ renderBrief={renderBrief}
renderMessageActions={renderMessageActions}
renderQueuedAction={renderQueuedAction}
onTryAgainNoLiveExecutionPath={
diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx
index cbcc99d9b7..c6abf055a1 100644
--- a/ui/src/components/task-chat/TaskChatBubble.tsx
+++ b/ui/src/components/task-chat/TaskChatBubble.tsx
@@ -1,4 +1,4 @@
-import { useContext, useState, type ReactNode } from "react";
+import { useCallback, useContext, useState, type ReactNode } from "react";
import type { IssueAttachment } from "@paperclipai/shared";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { cn } from "@/lib/utils";
@@ -156,9 +156,11 @@ export function TaskChatBubble({
// Task attachments share the page gallery; standalone images retain the bubble viewer.
const openIssueGallery = useContext(IssueGalleryContext);
const [lightboxSrc, setLightboxSrc] = useState(null);
- const openImage = (src: string) => {
+ // Keep MarkdownBody's memo boundary intact when only the live tail changes.
+ // A fresh callback here reparses every historical response on every update.
+ const openImage = useCallback((src: string) => {
if (!openIssueGallery?.(src)) setLightboxSrc(src);
- };
+ }, [openIssueGallery]);
if (item.interstitial) {
// Interstitial updates are ephemeral (PAP-361): while streaming the text
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
diff --git a/ui/src/components/task-chat/TaskChatThreadView.performance.test.tsx b/ui/src/components/task-chat/TaskChatThreadView.performance.test.tsx
new file mode 100644
index 0000000000..e9fed6e648
--- /dev/null
+++ b/ui/src/components/task-chat/TaskChatThreadView.performance.test.tsx
@@ -0,0 +1,77 @@
+// @vitest-environment jsdom
+
+import { memo } from "react";
+import { flushSync } from "react-dom";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { TaskChatThreadView } from "./TaskChatThreadView";
+import { IssueGalleryContext } from "@/context/IssueGalleryContext";
+import type { TaskChatItem, TaskChatMessageItem } from "./task-chat-model";
+
+const renders = vi.hoisted(() => vi.fn());
+vi.mock("@/components/MarkdownBody", () => ({
+ // Preserve the real markdown component's memo contract while counting work.
+ MarkdownBody: memo((props: { children: string; onImageClick?: (src: string) => void }) => {
+ renders(props.children);
+ return ;
+ }),
+}));
+
+let host: HTMLDivElement;
+let root: Root;
+beforeEach(() => {
+ renders.mockClear();
+ host = document.body.appendChild(document.createElement("div"));
+ root = createRoot(host);
+});
+afterEach(() => {
+ flushSync(() => root.unmount());
+ host.remove();
+});
+
+const history: TaskChatMessageItem[] = Array.from({ length: 200 }, (_, index) => ({
+ id: `message-${index}`, kind: "message", author: "agent", text: `Response ${index}`,
+}));
+
+it("does no historical render work for a live-tail-only update", () => {
+ const actions = vi.fn(() => null);
+ const render = (tick: number) => flushSync(() => root.render(
+ Live {tick}
} />,
+ ));
+ render(0);
+ renders.mockClear();
+ actions.mockClear();
+ for (let tick = 1; tick <= 10; tick++) render(tick);
+ expect(renders).not.toHaveBeenCalled();
+ expect(actions).not.toHaveBeenCalled();
+ expect(host.textContent).toContain("Live 10");
+});
+
+it("does not reparse unchanged markdown when transcript projection recreates rows", () => {
+ const render = (items: TaskChatItem[]) => flushSync(() => root.render(
+ ,
+ ));
+ render(history);
+ renders.mockClear();
+ for (let tick = 0; tick < 10; tick++) render(history.map((item) => ({ ...item })));
+ expect(renders).not.toHaveBeenCalled();
+ render(history.map((item, index) => index === 199 ? { ...item, text: "Edited response" } : item));
+ expect(renders).toHaveBeenCalledExactlyOnceWith("Edited response");
+ expect(host.textContent).toContain("Edited response");
+});
+
+it("uses the current gallery callback after the provider changes", () => {
+ const firstGallery = vi.fn(() => true);
+ const nextGallery = vi.fn(() => true);
+ const render = (gallery: (src: string) => boolean) => flushSync(() => root.render(
+
+
+ ,
+ ));
+ render(firstGallery);
+ flushSync(() => host.querySelector("button")!.click());
+ render(nextGallery);
+ flushSync(() => host.querySelector("button")!.click());
+ expect(firstGallery).toHaveBeenCalledExactlyOnceWith("image.png");
+ expect(nextGallery).toHaveBeenCalledExactlyOnceWith("image.png");
+});
diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx
index b4d9a1bee8..3f5a1aa503 100644
--- a/ui/src/components/task-chat/TaskChatThreadView.tsx
+++ b/ui/src/components/task-chat/TaskChatThreadView.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from "react";
+import { useMemo, type ReactNode } from "react";
import type { IssueAttachment } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
@@ -22,6 +22,8 @@ import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
+const EMPTY_ATTACHMENTS: IssueAttachment[] = [];
+
interface TaskChatThreadViewProps {
items: TaskChatItem[];
/**
@@ -317,7 +319,7 @@ export function TaskChatThreadView({
contentKey,
className,
scroll = true,
- attachments = [],
+ attachments = EMPTY_ATTACHMENTS,
}: TaskChatThreadViewProps) {
const streamlined = useStreamlinedTaskChatPresentation();
const retryableMarkerId =
@@ -332,29 +334,96 @@ export function TaskChatThreadView({
item.label === "Usage limit reached"),
)?.id
: undefined;
- const renderedItems = streamlined
- ? items
- .map((item) => ({
- item,
- content: renderItem(
+ // Streaming tail and header updates must not rebuild settled markdown/tool trees.
+ const history = useMemo(() => {
+ const renderedItems = streamlined
+ ? items
+ .map((item) => ({
item,
- onApprovalDecision,
- renderInteraction,
- renderBrief,
- renderMessageActions,
- renderQueuedAction,
- onRuntimeRequestDecision,
- "classic",
- onTryAgainNoLiveExecutionPath,
- tryAgainNoLiveExecutionPathPending,
- retryableMarkerId,
- onRetryFailedRun,
- retryFailedRunId,
- attachments,
- ),
- }))
- .filter((entry) => entry.content !== null)
- : [];
+ content: renderItem(
+ item,
+ onApprovalDecision,
+ renderInteraction,
+ renderBrief,
+ renderMessageActions,
+ renderQueuedAction,
+ onRuntimeRequestDecision,
+ "classic",
+ onTryAgainNoLiveExecutionPath,
+ tryAgainNoLiveExecutionPathPending,
+ retryableMarkerId,
+ onRetryFailedRun,
+ retryFailedRunId,
+ attachments,
+ ),
+ }))
+ .filter((entry) => entry.content !== null)
+ : [];
+ return (
+ <>
+ {streamlined
+ ? renderedItems.map(({ item, content }, index) => (
+
+ {content}
+
+ ))
+ : items.map((item, index) => (
+ 0 &&
+ item.kind === "interaction" &&
+ item.interaction.status !== "pending" &&
+ "-mt-3",
+ )}
+ >
+ {renderItem(
+ item,
+ onApprovalDecision,
+ renderInteraction,
+ renderBrief,
+ renderMessageActions,
+ renderQueuedAction,
+ onRuntimeRequestDecision,
+ "classic",
+ onTryAgainNoLiveExecutionPath,
+ tryAgainNoLiveExecutionPathPending,
+ retryableMarkerId,
+ onRetryFailedRun,
+ retryFailedRunId,
+ attachments,
+ )}
+
+ ))}
+ >
+ );
+ }, [
+ items, streamlined, onApprovalDecision, onRuntimeRequestDecision,
+ renderInteraction, renderBrief, renderMessageActions, renderQueuedAction,
+ onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending,
+ retryableMarkerId, onRetryFailedRun, retryFailedRunId, attachments,
+ ]);
const body = (
) : null}
- {streamlined
- ? renderedItems.map(({ item, content }, index) => (
-
- {content}
-
- ))
- : items.map((item, index) => (
-
0 &&
- item.kind === "interaction" &&
- item.interaction.status !== "pending" &&
- "-mt-3",
- )}
- >
- {renderItem(
- item,
- onApprovalDecision,
- renderInteraction,
- renderBrief,
- renderMessageActions,
- renderQueuedAction,
- onRuntimeRequestDecision,
- "classic",
- onTryAgainNoLiveExecutionPath,
- tryAgainNoLiveExecutionPathPending,
- retryableMarkerId,
- onRetryFailedRun,
- retryFailedRunId,
- attachments,
- )}
-
- ))}
+ {history}
{tail ? streamlined ?
{tail}
: tail : null}
);
diff --git a/ui/src/components/task-chat/TaskChatTurn.test.tsx b/ui/src/components/task-chat/TaskChatTurn.test.tsx
index b531ec878b..6e2acff57b 100644
--- a/ui/src/components/task-chat/TaskChatTurn.test.tsx
+++ b/ui/src/components/task-chat/TaskChatTurn.test.tsx
@@ -344,7 +344,7 @@ describe("TaskChatTurn", () => {
],
});
- expect(fold()?.textContent).toContain("c1");
+ expect(fold()?.textContent).not.toContain("c1");
expect(fold()?.textContent).not.toContain("question-history");
const history = container.querySelector(
'[data-testid="task-chat-turn-persistent-history"]',
@@ -355,7 +355,9 @@ describe("TaskChatTurn", () => {
it("toggles open on summary click", () => {
renderTurn(SETTLED);
+ expect(fold()?.textContent).not.toContain("c1");
flushSync(() => summaryBtn()!.click());
+ expect(fold()?.textContent).toContain("c1");
expect(fold()?.getAttribute("data-folded")).toBe("false");
expect(
summaryBtn()
diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx
index 8361b017d4..8545e892f0 100644
--- a/ui/src/components/task-chat/TaskChatTurn.tsx
+++ b/ui/src/components/task-chat/TaskChatTurn.tsx
@@ -157,6 +157,10 @@ export function TaskChatTurn({
const [open, setOpen] = useState(
() => !item.settled && item.liveStatus == null,
);
+ // Historical folds can contain thousands of tool/reasoning rows. Mount them
+ // on first inspection, then retain them for closing motion and child state.
+ const [historyMounted, setHistoryMounted] = useState(open);
+ if (open && !historyMounted) setHistoryMounted(true);
const [prevSettled, setPrevSettled] = useState(item.settled);
const [wasParentRow, setWasParentRow] = useState(parentRow);
@@ -269,7 +273,7 @@ export function TaskChatTurn({
>
- {foldedItems.map((child) => (
+ {historyMounted && foldedItems.map((child) => (
{renderChild(child)}
))}
diff --git a/ui/src/fixtures/TaskChatPerfHarness.tsx b/ui/src/fixtures/TaskChatPerfHarness.tsx
new file mode 100644
index 0000000000..5b9594e96f
--- /dev/null
+++ b/ui/src/fixtures/TaskChatPerfHarness.tsx
@@ -0,0 +1,57 @@
+import { useEffect, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { MemoryRouter } from "react-router-dom";
+import { ThemeProvider } from "@/context/ThemeContext";
+import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
+import type { TaskChatItem } from "@/components/task-chat/task-chat-model";
+import "@/index.css";
+
+const paragraphs = Array.from({ length: 8 }, (_, i) =>
+ `### Investigation ${i + 1}\n\nThe agent inspected the implementation, checked **boundary conditions**, and recorded the result.\n\n- Preserve existing behavior.\n- Verify the next update.\n\n\`\`\`ts\nconst result = await inspect({ attempt: ${i} });\n\`\`\``,
+).join("\n\n");
+const history: TaskChatItem[] = Array.from({ length: 200 }, (_, index) => ({
+ id: `history-${index}`,
+ kind: "message",
+ author: "agent",
+ authorName: "Engineer",
+ text: `Historical response ${index}\n\n${paragraphs}`,
+ timestamp: "12:00 PM",
+ attachedTurn: {
+ id: `turn-${index}`,
+ kind: "turn",
+ settled: true,
+ summary: { toolCount: 20, added: 0, removed: 0, durationLabel: "38s" },
+ items: Array.from({ length: 20 }, (_, tool) => ({
+ id: `tool-${index}-${tool}`, kind: "tool", name: "Read", status: "completed", target: `src/file-${tool}.ts`, detail: "File inspected successfully.",
+ })),
+ },
+}));
+const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+function Harness() {
+ const [streaming, setStreaming] = useState(false);
+ const [reproject, setReproject] = useState(false);
+ const [tick, setTick] = useState(0);
+ useEffect(() => {
+ if (!streaming) return;
+ const timer = window.setInterval(() => setTick((value) => value + 1), 100);
+ return () => window.clearInterval(timer);
+ }, [streaming]);
+ return (
+
+
+
({ ...item })) : history} contentKey={tick} tail={Live response {tick}
} />
+
+
+ );
+}
+
+createRoot(document.getElementById("root")!).render(
+
,
+);
diff --git a/ui/tests/task-chat-perf.html b/ui/tests/task-chat-perf.html
new file mode 100644
index 0000000000..d17ae6a362
--- /dev/null
+++ b/ui/tests/task-chat-perf.html
@@ -0,0 +1,5 @@
+
+
+
Task chat scrollback performance
+
+