perf(ui): keep long task chat responsive during streaming (#13229)

## Thinking Path

> - Paperclip lets operators manage agent work through tasks.
> - Task chat keeps responses and run history together.
> - A live update rendered every historical bubble and hidden tool row
again.
> - New image callbacks also forced unchanged markdown to parse again.
> - Long conversations saturated the browser main thread.
> - This change reuses unchanged history and mounts folded tools on
first inspection.
> - Operators can read and reply while work continues.

## Linked Issues or Issue Description

**What happened?**

Chat-style tasks with substantial scrollback became almost unusable. A
deterministic browser reproduction with 200 long responses and 4,000
tools consumed 97.8% of the main thread during live updates. It
delivered only 8 updates during the sample.

**Expected behavior**

The task should remain responsive during streaming. Historical markdown
and unopened run details should not repeat expensive render work.

**Steps to reproduce**

1. Install dependencies with `pnpm install`.
2. Run `pnpm exec playwright test --config
tests/perf/task-chat/playwright.config.ts`.
3. Compare the attached performance JSON. The new test fails against the
original rendering code.

**Paperclip version or commit**

Reproduced at `a05b828bc`. The branch is rebased on current master.

**Deployment mode**

Local Chromium and Vite with deterministic fixtures. No database or
agent credentials are required.

Related work: #10463 reduces the issue-page bundle. This change
addresses repeated rendering after the page loads. No duplicate
scrollback fix was found.

## What Changed

- Keep the bubble image callback stable so unchanged markdown can skip
parsing.
- Memoize the settled history separately from the header and streaming
tail. Keep the brief renderer and default attachment array stable.
- Mount folded tool history on first expansion. Keep it mounted
afterward to preserve child state and closing motion. Runtime request
receipts remain visible.
- Add deterministic rendering tests to the normal Vitest suite and an
opt-in Chromium regression fixture.
- Document the reproduction, commands, scope, and local measurements.

## Verification

- Browser reproduction: 97.8% main-thread utilization before; 11.6%
after for tail-only updates; 31.5% after when projection recreates
history objects. Both fixed cases delivered 32 updates.
- Browser checks pass for scroll-position retention, typing, return to
latest, tool inspection, and retained expansion state.
- Focused component suite: 155 tests passed. Post-rebase
thread/performance rerun: 101 tests passed.
- Recursive typecheck, build, Storybook build, and token gates passed.
UI typecheck passed after the final edits.
- Local UI/CLI lane: 5,910 tests passed; ten files hit worker-start
timeouts, then all ten passed with two workers (20 tests).
Shared/adapter lane: 3,128 tests passed.
- Full local `pnpm test:run` was attempted and is **not green**: its
server lane recorded 10,517 passes and 11 failures plus fixture/setup
errors. Queue (31 tests), Cursor/Git-load (9 tests), and missing-binary
failures cleared on isolated reruns / building the runner test binaries.
Two native suites still cannot initialize embedded PostgreSQL on this
host.
- The remaining native-session recovery assertion was reproduced in a
clean worktree at base `a20ecce40` (1 failed, 67 passed across the
native/queue suites). It expects a settled-session error but receives a
semantic-tool-input digest error. No server or runner files changed in
this PR.
- All substantive CI jobs have passed, including build, typecheck, all
server/workspace test shards, all three e2e shards, and the canary dry
run. The unchanged Slack ordering test exhausted its one-second wait on
the first run; its shard passed on rerun. Final aggregate verification
passed: **31 passing checks**, no failures or pending checks; two
optional Storybook deployment/visual checks were skipped. Greptile is
**5/5**, with no unresolved review threads.
- The supplemental local serialized route run was stopped after CI
passed all five serialized shards; it had reported no failures.
- The browser fixture uses the real chat rendering components with a
plain textarea. It does not test the full composer or server transport.
Timing results are local samples; deterministic render-count tests
provide normal CI coverage.

## Risks

- Closed tool content becomes available to DOM search only after first
expansion. Visible run summaries and runtime request receipts remain
available immediately.
- Opened run history remains mounted to preserve child state. The first
full markdown render still scales with conversation size.
- Memo dependencies must stay current when adding render inputs. Tests
check content edits and replacement gallery callbacks.
- No API, database, or permission changes.

## Model Used

OpenAI GPT-6 (Codex). Exact serving variant and context-window size are
not exposed in this session. Used reasoning, repository tools, code
execution, and browser testing. No sub-agents were used.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass — targeted/UI/workspace
checks pass; the full local server suite has the baseline/host failures
documented above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 10:15:00 -05:00 committed by GitHub
parent 1616046c24
commit 3b03c4b9eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 367 additions and 89 deletions

View File

@ -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.

View File

@ -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,
},
});

View File

@ -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);
});
}

View File

@ -2454,6 +2454,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
],
);
const renderBrief = useCallback(
() => issueBrief ? <TaskChatDescriptionBubble brief={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
? () => <TaskChatDescriptionBubble brief={issueBrief} />
: undefined
}
renderBrief={renderBrief}
renderMessageActions={renderMessageActions}
renderQueuedAction={renderQueuedAction}
onTryAgainNoLiveExecutionPath={

View File

@ -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<string | null>(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

View File

@ -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 <button onClick={() => props.onImageClick?.("image.png")}>{props.children}</button>;
}),
}));
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(
<TaskChatThreadView items={history} scroll={false} renderMessageActions={actions} tail={<p>Live {tick}</p>} />,
));
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(
<TaskChatThreadView items={items} scroll={false} />,
));
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(
<IssueGalleryContext.Provider value={gallery}>
<TaskChatThreadView items={history.slice(0, 1)} scroll={false} />
</IssueGalleryContext.Provider>,
));
render(firstGallery);
flushSync(() => host.querySelector<HTMLButtonElement>("button")!.click());
render(nextGallery);
flushSync(() => host.querySelector<HTMLButtonElement>("button")!.click());
expect(firstGallery).toHaveBeenCalledExactlyOnceWith("image.png");
expect(nextGallery).toHaveBeenCalledExactlyOnceWith("image.png");
});

View File

@ -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) => (
<div
key={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
data-thread-anchor={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
id={item.kind === "message" ? `comment-${item.id}` : undefined}
className={taskChatItemSpacingClass(
item,
renderedItems[index - 1]?.item ?? null,
)}
data-thread-item-kind={
item.kind === "message" ? item.author : item.kind
}
>
{content}
</div>
))
: items.map((item, index) => (
<div
key={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
data-thread-anchor={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
id={item.kind === "message" ? `comment-${item.id}` : undefined}
className={cn(
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,
)}
</div>
))}
</>
);
}, [
items, streamlined, onApprovalDecision, onRuntimeRequestDecision,
renderInteraction, renderBrief, renderMessageActions, renderQueuedAction,
onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending,
retryableMarkerId, onRetryFailedRun, retryFailedRunId, attachments,
]);
const body = (
<div
className={cn(
@ -371,61 +440,7 @@ export function TaskChatThreadView({
{header}
</div>
) : null}
{streamlined
? renderedItems.map(({ item, content }, index) => (
<div
key={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
data-thread-anchor={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
id={item.kind === "message" ? `comment-${item.id}` : undefined}
className={taskChatItemSpacingClass(
item,
renderedItems[index - 1]?.item ?? null,
)}
data-thread-item-kind={
item.kind === "message" ? item.author : item.kind
}
>
{content}
</div>
))
: items.map((item, index) => (
<div
key={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
data-thread-anchor={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
id={item.kind === "message" ? `comment-${item.id}` : undefined}
className={cn(
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,
)}
</div>
))}
{history}
{tail ? streamlined ? <div className="mt-4">{tail}</div> : tail : null}
</div>
);

View File

@ -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()

View File

@ -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({
>
<div>
<div className="flex flex-col gap-2 pt-1">
{foldedItems.map((child) => (
{historyMounted && foldedItems.map((child) => (
<div key={child.id}>{renderChild(child)}</div>
))}
</div>

View File

@ -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 (
<div className="flex h-dvh flex-col px-6">
<header className="flex shrink-0 items-center gap-4 py-3">
<h1>Task chat: 200 responses, 4,000 tools</h1>
<button onClick={() => setStreaming((value) => !value)}>{streaming ? "Stop streaming" : "Start streaming"}</button>
<label><input type="checkbox" checked={reproject} onChange={(event) => setReproject(event.target.checked)} /> Recreate history objects</label>
<output data-testid="stream-tick">{tick}</output>
</header>
<TaskChatThreadView items={reproject ? history.map((item) => ({ ...item })) : history} contentKey={tick} tail={<p>Live response {tick}</p>} />
<textarea className="shrink-0 border p-3" aria-label="Reply" placeholder="Reply to the task" />
</div>
);
}
createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={client}><MemoryRouter><ThemeProvider><Harness /></ThemeProvider></MemoryRouter></QueryClientProvider>,
);

View File

@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Task chat scrollback performance</title></head>
<body><div id="root"></div><script type="module" src="/src/fixtures/TaskChatPerfHarness.tsx"></script></body>
</html>