mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +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>
522 lines
17 KiB
TypeScript
522 lines
17 KiB
TypeScript
// @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");
|
||
});
|
||
});
|