Files
PaperClipAI/ui/src/components/task-chat/TaskChatTurn.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

522 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TaskChatTurn, turnSummaryText } from "./TaskChatTurn";
import { buildTurnSummary } from "./transcript-adapter";
import { ThemeProvider } from "@/context/ThemeContext";
import { MemoryRouter } from "@/lib/router";
import type { TranscriptEntry } from "@/adapters";
import type {
TaskChatStatusItem,
TaskChatToolStatus,
TaskChatTurnChildItem,
TaskChatTurnItem,
} from "./task-chat-model";
const tool = (
id: string,
status: TaskChatToolStatus = "completed",
): TaskChatTurnChildItem => ({
id,
kind: "tool",
name: "Read",
status,
});
const SETTLED: TaskChatTurnItem = {
id: "t1",
kind: "turn",
settled: true,
summary: {
durationLabel: "38s",
toolCount: 3,
added: 34,
removed: 3,
tokensLabel: "12.3k tokens",
},
items: [
{ id: "c1", kind: "tool", name: "read auth.ts", status: "completed" },
],
};
// Non-generic label so the header shows it verbatim (no whimsy substitution).
const LIVE_STATUS: TaskChatStatusItem = {
id: "t1:status",
kind: "status",
status: "working",
label: "Editing files",
detail: "Edit · server/src/routes/auth.ts",
toolName: "Edit",
};
const parentRowTurn = (
children: TaskChatTurnChildItem[],
status: TaskChatStatusItem = LIVE_STATUS,
): TaskChatTurnItem => ({
...SETTLED,
settled: false,
liveStatus: status,
items: children,
});
describe("turnSummaryText", () => {
it("joins the known parts with dots", () => {
expect(turnSummaryText(SETTLED.summary)).toBe(
"Worked · 38s · 3 tools · +34 −3 · 12.3k tokens",
);
});
it("omits unknown parts and singularizes one tool", () => {
expect(turnSummaryText({ toolCount: 1, added: 0, removed: 0 })).toBe(
"Worked · 1 tool",
);
});
it("labels failed turns Stopped", () => {
expect(
turnSummaryText({ toolCount: 0, added: 0, removed: 0, failed: true }),
).toBe("Stopped");
});
});
describe("buildTurnSummary", () => {
it("counts tools, diff lines and result tokens from a transcript", () => {
const entries: TranscriptEntry[] = [
{
kind: "tool_call",
ts: "2026-07-29T10:00:00Z",
name: "edit",
input: {},
},
{
kind: "diff",
ts: "2026-07-29T10:00:05Z",
changeType: "add",
text: "+a",
},
{
kind: "diff",
ts: "2026-07-29T10:00:05Z",
changeType: "remove",
text: "-b",
},
{
kind: "result",
ts: "2026-07-29T10:00:38Z",
text: "done",
inputTokens: 12000,
outputTokens: 300,
cachedTokens: 0,
costUsd: 0.01,
subtype: "success",
isError: false,
errors: [],
},
];
const summary = buildTurnSummary(entries);
expect(summary.toolCount).toBe(1);
expect(summary.added).toBe(1);
expect(summary.removed).toBe(1);
expect(summary.tokensLabel).toBe("12.3k tokens");
expect(summary.durationLabel).toBe("38s");
});
it("prefers an explicit duration and flags failure", () => {
const summary = buildTurnSummary([], { durationMs: 95_000, failed: true });
expect(summary.durationLabel).toBe("1m 35s");
expect(summary.failed).toBe(true);
});
});
describe("TaskChatTurn", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
});
const renderTurn = (item: TaskChatTurnItem) => {
flushSync(() => {
root.render(
<MemoryRouter>
<ThemeProvider>
<TaskChatTurn
item={item}
renderChild={(c) => <span>{c.id}</span>}
/>
</ThemeProvider>
</MemoryRouter>,
);
});
};
const fold = () => container.querySelector(".tc-turn-fold");
const summaryBtn = () =>
container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-turn-summary"]',
);
const liveHeaderBtn = () =>
container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-live-turn"]',
);
it("renders a settled turn folded with its summary line", () => {
renderTurn(SETTLED);
// Label and mono metrics are adjacent spans (v7 runsum grammar).
expect(summaryBtn()?.textContent).toContain("Worked");
expect(summaryBtn()?.textContent).toContain(
"38s · 3 tools · +34 −3 · 12.3k tokens",
);
expect(fold()?.getAttribute("data-folded")).toBe("true");
expect(summaryBtn()?.lastElementChild?.tagName).toBe("svg");
expect(
summaryBtn()
?.querySelector(".tc-turn-metrics")
?.getAttribute("data-visible"),
).toBe("false");
});
it("renders the new-runner Worked header above its settled timeline", () => {
renderTurn({
...SETTLED,
standaloneHeader: true,
agentName: "Codex",
agentIcon: "code",
});
expect(summaryBtn()?.textContent).toContain("Worked for 38s");
expect(summaryBtn()?.textContent).toContain("Codex");
expect(summaryBtn()?.textContent).not.toContain("3 tools");
expect(
summaryBtn()?.querySelector('[data-testid="task-chat-agent-avatar"]'),
).not.toBeNull();
expect(fold()).toBeNull();
const timeline = container.querySelector(
'[data-testid="task-chat-turn-timeline"]',
);
expect(timeline?.textContent).toContain("c1");
expect(summaryBtn()?.getAttribute("data-turn-position")).toBe("identity");
expect(summaryBtn()?.classList.contains("pb-1")).toBe(true);
expect(summaryBtn()?.classList.contains("pt-1.5")).toBe(true);
expect(summaryBtn()?.classList.contains("border-b")).toBe(false);
expect(summaryBtn()?.compareDocumentPosition(timeline!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
});
it("labels a later segment as a continuation of the steered run", () => {
renderTurn({
...SETTLED,
standaloneHeader: true,
continuedAfterSteering: true,
agentName: "Codex",
});
expect(summaryBtn()?.textContent).toContain(
"Continued after steering · Worked for 38s",
);
});
it("keeps a yielded runner summary after the settled timeline", () => {
renderTurn({
...SETTLED,
standaloneHeader: true,
finalResponse: {
id: "result-summary",
kind: "message",
author: "agent",
text: "Waiting for Review browser RTS plan.",
channel: "final",
},
});
const timeline = container.querySelector(
'[data-testid="task-chat-turn-timeline"]',
);
const final = container.querySelector(
'[data-testid="task-chat-final-response"]',
);
expect(final?.textContent).toContain(
"Waiting for Review browser RTS plan.",
);
expect(timeline?.compareDocumentPosition(final!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
});
it("keeps standalone commentary phases and question receipts in projected order", () => {
renderTurn({
...SETTLED,
standaloneHeader: true,
items: [
{
id: "commentary-1:phase",
kind: "activity_phase",
interstitial: {
id: "commentary-1",
kind: "message",
author: "agent",
text: "First commentary",
interstitial: true,
channel: "progress",
},
items: [
{
id: "tool-1",
kind: "tool",
name: "Read",
status: "completed",
},
],
summary: "Read 1 file",
active: false,
},
{
id: "question-1",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "question-1",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "resolved",
prompt: "Question one",
choices: [],
fields: [],
},
{
id: "commentary-2:phase",
kind: "activity_phase",
interstitial: {
id: "commentary-2",
kind: "message",
author: "agent",
text: "Second commentary",
interstitial: true,
channel: "progress",
},
items: [],
summary: "No tool activity",
active: false,
},
],
});
expect(
Array.from(
container.querySelectorAll(
'[data-testid="task-chat-turn-timeline-row"]',
),
).map((row) => row.getAttribute("data-timeline-row-id")),
).toEqual(["commentary-1:phase", "question-1", "commentary-2:phase"]);
});
it("keeps settled runtime question history outside the Worked fold", () => {
renderTurn({
...SETTLED,
items: [
SETTLED.items[0]!,
{
id: "question-history",
kind: "protocol",
surface: "runtime_request",
runId: "run-1",
requestId: "question-1",
requestKind: "runtime",
turnId: "turn-1",
requestType: "input",
status: "resolved",
prompt: "Choose an environment",
choices: [],
fields: [],
},
],
});
expect(fold()?.textContent).not.toContain("c1");
expect(fold()?.textContent).not.toContain("question-history");
const history = container.querySelector(
'[data-testid="task-chat-turn-persistent-history"]',
);
expect(history?.textContent).toContain("question-history");
expect(history?.closest(".tc-turn-fold")).toBeNull();
});
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()
?.querySelector(".tc-turn-metrics")
?.getAttribute("data-visible"),
).toBe("true");
});
it("a headerless live turn renders expanded with no summary line", () => {
renderTurn({ ...SETTLED, settled: false });
expect(summaryBtn()).toBeNull();
expect(liveHeaderBtn()).toBeNull();
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("a headerless live turn folds when it settles while mounted", () => {
renderTurn({ ...SETTLED, settled: false });
renderTurn({ ...SETTLED, animateFold: true });
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("collapses a live parent-row turn to its single status line", () => {
renderTurn(parentRowTurn([tool("a"), tool("b"), tool("c", "in_progress")]));
const header = liveHeaderBtn();
expect(header?.textContent).toContain("Editing files…");
expect(header?.textContent).toContain("Edit · server/src/routes/auth.ts");
expect(header?.getAttribute("aria-expanded")).toBe("false");
expect(header?.firstElementChild?.firstElementChild?.tagName).toBe("svg");
// All activity is folded behind it — no rows visible, no summary line.
expect(fold()?.getAttribute("data-folded")).toBe("true");
expect(summaryBtn()).toBeNull();
});
it("flashes the in-flight tool state through the header", () => {
renderTurn(parentRowTurn([tool("a")]));
expect(liveHeaderBtn()?.textContent).toContain("Editing files…");
renderTurn(
parentRowTurn([tool("a")], {
...LIVE_STATUS,
label: "Reading a file",
detail: "Read · auth.ts",
toolName: "Read",
}),
);
expect(liveHeaderBtn()?.textContent).toContain("Reading a file…");
expect(liveHeaderBtn()?.textContent).toContain("Read · auth.ts");
});
it("expands to the nested chronological history on click", () => {
renderTurn(parentRowTurn([tool("a"), tool("b")]));
flushSync(() => liveHeaderBtn()!.click());
expect(liveHeaderBtn()?.getAttribute("aria-expanded")).toBe("true");
expect(fold()?.getAttribute("data-folded")).toBe("false");
expect(fold()?.textContent).toContain("a");
expect(fold()?.textContent).toContain("b");
});
it("appends new rows live while expanded and re-collapses on click", () => {
renderTurn(parentRowTurn([tool("a")]));
flushSync(() => liveHeaderBtn()!.click());
renderTurn(parentRowTurn([tool("a"), tool("b", "in_progress")]));
expect(fold()?.getAttribute("data-folded")).toBe("false");
expect(fold()?.textContent).toContain("b");
flushSync(() => liveHeaderBtn()!.click());
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("keeps the interstitial row outside the expand button's hover target (PAP-376)", () => {
// act() so the held-line effect state (useHeldSelfTalk) flushes.
act(() => {
renderTurn(
parentRowTurn([tool("a")], {
...LIVE_STATUS,
selfTalk: "Streaming an update.",
}),
);
});
const btn = liveHeaderBtn();
expect(btn).not.toBeNull();
// The button (hover + click target) wraps ONLY the gerund status line;
// the interstitial text renders above it, outside the button.
expect(
btn?.querySelector('[data-testid="task-chat-interstitial-row"]'),
).toBeNull();
expect(btn?.textContent).not.toContain("Streaming an update.");
const row = container.querySelector(
'[data-testid="task-chat-interstitial-row"]',
);
expect(row?.textContent).toContain("Streaming an update.");
// Expanding still works from the status line alone.
flushSync(() => btn!.click());
expect(liveHeaderBtn()?.getAttribute("aria-expanded")).toBe("true");
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("renders the status line without an expand affordance while there is no activity", () => {
renderTurn(parentRowTurn([]));
expect(liveHeaderBtn()).toBeNull();
expect(container.textContent).toContain("Editing files…");
});
it("morphs a collapsed parent row into the settled summary in place", () => {
renderTurn(parentRowTurn([tool("a")]));
renderTurn({ ...SETTLED, animateFold: true, items: [tool("a")] });
expect(liveHeaderBtn()).toBeNull();
expect(summaryBtn()?.textContent).toContain("Worked");
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("keeps the expand state across the settle morph", () => {
renderTurn(parentRowTurn([tool("a")]));
flushSync(() => liveHeaderBtn()!.click());
renderTurn({ ...SETTLED, animateFold: true, items: [tool("a")] });
expect(summaryBtn()?.getAttribute("aria-expanded")).toBe("true");
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("rides the `leading` slot on the header row, anchored above the fold (PAP-413)", () => {
flushSync(() => {
root.render(
<TaskChatTurn
item={SETTLED}
leading={<div data-testid="turn-lead">actions</div>}
renderChild={(c) => <span>{c.id}</span>}
/>,
);
});
const lead = container.querySelector('[data-testid="turn-lead"]');
expect(lead).not.toBeNull();
// It sits beside the summary button on the header row — NOT inside the
// expandable fold — so expanding the tool history can't drag it down.
expect(fold()?.contains(lead!)).toBe(false);
expect(summaryBtn()).not.toBeNull();
expect(summaryBtn()!.compareDocumentPosition(lead!) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
expect(lead?.parentElement?.parentElement?.className).toContain("justify-between");
// Expanding still works and the leading slot stays put outside the fold.
flushSync(() => summaryBtn()!.click());
expect(fold()?.getAttribute("data-folded")).toBe("false");
expect(fold()?.contains(lead!)).toBe(false);
});
it("leads the settled summary with the bubble timestamp when attached (round 9)", () => {
flushSync(() => {
root.render(
<TaskChatTurn
item={SETTLED}
timestampPrefix="2:34 PM"
renderChild={(c) => <span>{c.id}</span>}
/>,
);
});
// "2:34 PM · ✓ Worked · …" — timestamp first, always visible, and the
// expand affordance still works from the same line.
expect(summaryBtn()?.textContent).toMatch(/^2:34 PM·Worked/);
// The branch task view intentionally keeps the expand affordance at the
// end of the timestamp-led summary row.
expect(summaryBtn()?.lastElementChild?.tagName).toBe("svg");
flushSync(() => summaryBtn()!.click());
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
});