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:
parent
1616046c24
commit
3b03c4b9eb
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue