Files
PaperClipAI/ui/src/components/task-chat/TaskChatThreadView.performance.test.tsx
T
DottaandPaperclip 3b03c4b9eb 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>
2026-09-11 10:15:00 -05:00

78 lines
3.1 KiB
TypeScript

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