mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
## 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>
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
import { useState, type ReactNode } from "react";
|
||
import { cn } from "@/lib/utils";
|
||
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
|
||
import { Check, ChevronRight, X } from "lucide-react";
|
||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||
import type {
|
||
TaskChatTurnItem,
|
||
TaskChatTurnChildItem,
|
||
} from "./task-chat-model";
|
||
import { TaskChatStatusPill } from "./TaskChatStatusPill";
|
||
import { TaskChatAgentIdentity } from "./TaskChatBubble";
|
||
|
||
interface TaskChatTurnProps {
|
||
item: TaskChatTurnItem;
|
||
renderChild: (child: TaskChatTurnChildItem) => ReactNode;
|
||
/**
|
||
* When the settled turn is attached to its reply bubble (round 9), the
|
||
* bubble's timestamp leads the summary line — "2:34 PM · ✓ Worked · 38s" —
|
||
* always visible, in the slot the hover-only timestamp used to occupy.
|
||
*/
|
||
timestampPrefix?: string;
|
||
/**
|
||
* Content rendered on the header row (PAP-413: the copy/👍/👎 action
|
||
* cluster). The inspection caret and timestamp stay left; actions sit at the
|
||
* stable right edge, outside the expandable fold.
|
||
*/
|
||
leading?: ReactNode;
|
||
}
|
||
|
||
/** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */
|
||
export function turnSummaryMetrics(
|
||
summary: TaskChatTurnItem["summary"],
|
||
): string {
|
||
const parts: string[] = [];
|
||
if (summary.durationLabel) parts.push(summary.durationLabel);
|
||
if (summary.toolCount > 0)
|
||
parts.push(
|
||
`${summary.toolCount} tool${summary.toolCount === 1 ? "" : "s"}`,
|
||
);
|
||
if (summary.added > 0 || summary.removed > 0)
|
||
parts.push(`+${summary.added} −${summary.removed}`);
|
||
if (summary.tokensLabel) parts.push(summary.tokensLabel);
|
||
return parts.join(" · ");
|
||
}
|
||
|
||
/** "✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens" (parts omitted when unknown). */
|
||
export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
|
||
const metrics = turnSummaryMetrics(summary);
|
||
const label = summary.failed ? "Stopped" : "Worked";
|
||
return metrics ? `${label} · ${metrics}` : label;
|
||
}
|
||
|
||
/**
|
||
* One agent turn's activity behind a single expandable header row. Paperclip
|
||
* Runner turns opt into the chronological-timeline branch below instead.
|
||
*
|
||
* While the turn is live and carries `liveStatus`, that status line (whimsy or
|
||
* taxonomy gerund + elapsed + tokens) IS the turn: one parent row, collapsed
|
||
* by default, owning all activity (PAP-354). Expanding nests the chronological
|
||
* tool/thinking history underneath — each row keeping its own expand — and new
|
||
* rows append live while open. On settle the header morphs in place into the
|
||
* "✓ Worked · …" summary, preserving the expand state, so the running line
|
||
* reads as being replaced by the summary in the same slot.
|
||
*
|
||
* Both directions of the fold reuse the .tc-turn-fold grid-rows motion
|
||
* (--motion-turn-fold, zeroed under prefers-reduced-motion). The fold class
|
||
* only transitions on state CHANGE, so first paint in the folded state (e.g. a
|
||
* turn that loads already settled) never animates. A live turn without
|
||
* `liveStatus` (harness fixtures) renders its children expanded with no header
|
||
* and folds when it settles.
|
||
*/
|
||
export function TaskChatTurn({
|
||
item,
|
||
renderChild,
|
||
timestampPrefix,
|
||
leading,
|
||
}: TaskChatTurnProps) {
|
||
const streamlined = useStreamlinedTaskChatPresentation();
|
||
const parentRow = !item.settled && item.liveStatus != null;
|
||
// The new Paperclip Runner task surface owns one durable chronological
|
||
// timeline. The Worked/Stopped row is its stable header, so it stays directly
|
||
// below the preceding human bubble and above commentary, activity phases,
|
||
// request receipts, and plan artifacts. The classic task interface continues
|
||
// to use the run-wide fold below.
|
||
if (item.standaloneHeader) {
|
||
return (
|
||
<div
|
||
data-testid="task-chat-turn"
|
||
data-settled={item.settled ? "true" : "false"}
|
||
>
|
||
<div
|
||
className="flex min-h-8 w-full min-w-0 items-center gap-2 pb-1 pt-1.5 text-sm text-muted-foreground"
|
||
data-testid="task-chat-turn-summary"
|
||
data-turn-position="identity"
|
||
>
|
||
{item.agentName ? (
|
||
<TaskChatAgentIdentity
|
||
agentName={item.agentName}
|
||
agentIcon={item.agentIcon}
|
||
/>
|
||
) : null}
|
||
<span className="min-w-0 truncate">
|
||
{item.continuedAfterSteering ? "Continued after steering · " : ""}
|
||
{item.summary.durationLabel
|
||
? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}`
|
||
: item.summary.failed
|
||
? "Stopped"
|
||
: "Worked"}
|
||
</span>
|
||
</div>
|
||
{item.items.length > 0 ? (
|
||
<div
|
||
className="flex min-w-0 flex-col gap-2 py-1"
|
||
data-testid="task-chat-turn-timeline"
|
||
>
|
||
{item.items.map((child) => (
|
||
<div
|
||
className="min-w-0"
|
||
key={child.id}
|
||
data-testid="task-chat-turn-timeline-row"
|
||
data-timeline-row-id={child.id}
|
||
data-thread-anchor={child.id}
|
||
>
|
||
{renderChild(child)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{item.finalResponse ? (
|
||
<div className="w-full" data-testid="task-chat-final-response">
|
||
<div
|
||
className="break-words px-1 py-2 text-sm text-foreground"
|
||
data-testid="task-chat-agent-bubble"
|
||
>
|
||
<MarkdownBody softBreaks linkIssueReferences>
|
||
{item.finalResponse.text}
|
||
</MarkdownBody>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
const persistentItems = item.settled
|
||
? item.items.filter(
|
||
(child) =>
|
||
child.kind === "protocol" && child.surface === "runtime_request",
|
||
)
|
||
: [];
|
||
const persistentItemIds = new Set(persistentItems.map((child) => child.id));
|
||
const foldedItems =
|
||
persistentItems.length > 0
|
||
? item.items.filter((child) => !persistentItemIds.has(child.id))
|
||
: item.items;
|
||
// Parent-row live turns and settled turns start as their one-line header;
|
||
// only the headerless legacy live turn starts expanded.
|
||
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);
|
||
|
||
// Render-adjusted state (not effects) so transitions land in the same
|
||
// commit — no flash of the wrong fold state.
|
||
if (parentRow && !wasParentRow) setWasParentRow(true);
|
||
// Live → settled while mounted: a parent-row turn keeps its expand state
|
||
// (the header morphs in place); a headerless expanded turn folds (animated
|
||
// via CSS unless reduced motion).
|
||
if (item.settled !== prevSettled) {
|
||
setPrevSettled(item.settled);
|
||
if (item.settled && !wasParentRow) setOpen(false);
|
||
}
|
||
|
||
const expandable = foldedItems.length > 0;
|
||
const folded = (item.settled || parentRow) && !open;
|
||
const SummaryIcon = item.summary.failed ? X : Check;
|
||
|
||
const header = item.settled ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((o) => !o)}
|
||
aria-expanded={open}
|
||
className={cn(
|
||
"group flex w-full items-center gap-2 px-1 text-muted-foreground transition-colors hover:text-foreground",
|
||
item.standaloneHeader
|
||
? "border-b border-border/70 py-2 text-sm"
|
||
: "py-0.5 text-xs",
|
||
streamlined && "min-w-0",
|
||
)}
|
||
data-testid="task-chat-turn-summary"
|
||
>
|
||
{timestampPrefix ? (
|
||
<>
|
||
<span className="text-(length:--text-micro)">{timestampPrefix}</span>
|
||
<span aria-hidden className="text-(length:--text-micro)">
|
||
·
|
||
</span>
|
||
</>
|
||
) : null}
|
||
{!item.standaloneHeader ? (
|
||
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
|
||
) : null}
|
||
<span>
|
||
{item.standaloneHeader && item.summary.durationLabel
|
||
? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}`
|
||
: item.summary.failed
|
||
? "Stopped"
|
||
: "Worked"}
|
||
</span>
|
||
{!item.standaloneHeader && turnSummaryMetrics(item.summary) ? (
|
||
// Time/tools/tokens is demoted, not deleted (PAP-502): it stays in the
|
||
// DOM (and the accessible tree) but fades in only on hover/focus so the
|
||
// settled line reads as "2:34 PM · ✓ Worked" at rest. Revealed too when
|
||
// the fold is open, so the metrics don't vanish while you read below.
|
||
<span
|
||
className="tc-turn-metrics"
|
||
data-visible={open ? "true" : "false"}
|
||
>
|
||
<span className="min-w-0 overflow-hidden whitespace-nowrap font-mono text-(length:--text-micro)">
|
||
{turnSummaryMetrics(item.summary)}
|
||
</span>
|
||
</span>
|
||
) : null}
|
||
<ChevronRight
|
||
className={cn(
|
||
"h-3 w-3 shrink-0 transition-transform",
|
||
item.standaloneHeader && "ml-auto",
|
||
open ? "rotate-90" : null,
|
||
)}
|
||
aria-hidden
|
||
/>
|
||
</button>
|
||
) : parentRow ? (
|
||
// The pill renders the expand button itself, wrapped around only the
|
||
// gerund status line — the interstitial row above stays outside the
|
||
// hover/click target (PAP-376). Without activity there is no
|
||
// chevron/button.
|
||
<TaskChatStatusPill
|
||
item={item.liveStatus!}
|
||
chevronOpen={expandable ? open : undefined}
|
||
onToggle={expandable ? () => setOpen((o) => !o) : undefined}
|
||
/>
|
||
) : null;
|
||
|
||
return (
|
||
<div
|
||
data-testid="task-chat-turn"
|
||
data-settled={item.settled ? "true" : "false"}
|
||
>
|
||
{leading ? (
|
||
streamlined ? (
|
||
<div className="flex w-full items-center justify-between gap-2">
|
||
<div className="min-w-0">{header}</div>
|
||
<div className="shrink-0">{leading}</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-1">
|
||
{leading}
|
||
{header}
|
||
</div>
|
||
)
|
||
) : (
|
||
header
|
||
)}
|
||
<div
|
||
className="tc-turn-fold"
|
||
data-folded={folded ? "true" : "false"}
|
||
aria-hidden={folded}
|
||
>
|
||
<div>
|
||
<div className="flex flex-col gap-2 pt-1">
|
||
{historyMounted && foldedItems.map((child) => (
|
||
<div key={child.id}>{renderChild(child)}</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{persistentItems.length > 0 ? (
|
||
<div
|
||
className="flex flex-col gap-2 pt-2"
|
||
data-testid="task-chat-turn-persistent-history"
|
||
>
|
||
{persistentItems.map((child) => (
|
||
<div key={child.id}>{renderChild(child)}</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|