Files
PaperClipAI/ui/src/components/task-chat/TaskChatTurn.test.tsx
T
scotttongandClaude Fable 5 e76584b95b feat(ui): task-chat phase 2b — v7 pixel parity + live streaming
Close the gaps between the flagged redesign and the accepted v7 prototype,
verified against the v7 artifact side-by-side and a REAL streaming agent run:

- Bubbles: human = --liveness-blue #2563eb (was bg-primary near-black) white
  text right; agent comment = borderless gray --bubble-agent bubble (new token,
  light+dark) with v7 header (capsule avatar · bold name · one capitalized
  mode chip). Bubble bodies now render markdown via MarkdownBody (code chips,
  links, mentions) instead of raw text.
- Composer: per-mode v7 placeholders ("Message {agent} — describe what you
  want done…"), neutral focus-within ring, borderless + attach iconbtn,
  full "Agent/Plan/Ask mode" label on the status-chip trigger, icon-only
  32px ArrowUp send with hover scale.
- Full-page chat shell: with enableTaskChatRedesign ON, IssueDetail gates the
  legacy title (compact single-line editor instead) / description / sub-tasks
  table / plan decompositions / Documents (pinned plan) / Output / attachments
  wall / workspace card / artifact chips — thread owns the center column,
  bounded by new --tc-thread-max-h so TaskMessageScroller owns scrolling with
  the composer pinned (scroll viewport = absolute inset-0; percentage heights
  don't resolve against flex-sized block parents). Flag OFF proven legacy:
  0 redesign testids, all legacy sections present.
- Tool rows (v7 flat grammar): [glyph · name · mono target · hover chevron ·
  status]; transcript adapter now emits real tool names (toolDisplayName —
  mcp__ collapse + capitalization, tool_result.toolName backfill), summarized
  primary params (summarizeToolInput probes file_path/command/pattern/…),
  clipped mono result detail, generic "Tool" fallback for legacy stored logs.
- Streaming states: shimmer (.shimmer-text-muted variant) on live Thinking…
  header + flat pulse-dot statusline (running/working no longer card chrome);
  thinking settles to "Thought for Ns" (ts-span derived) and arrives collapsed
  in settled history; turn summary label + mono metrics per v7 runsum.

Verified: motion gate (24 files, seams), token gates, workspace tsc, 177 ui
tests incl. task-chat/PropertiesPanel/IssueChatThread/IssueDetail; live
streaming captured on a real claude_local run on the worktree instance
(:3093 scratch task) — thinking/text deltas, live statusline with elapsed,
auto-follow, and fold-to-summary on completion; flag-off isolation screenshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:55:27 -07:00

110 lines
4.0 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 { 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 type { TranscriptEntry } from "@/adapters";
import type { TaskChatTurnItem } from "./task-chat-model";
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" }],
};
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(<TaskChatTurn item={item} renderChild={(c) => <span>{c.id}</span>} />);
});
};
const fold = () => container.querySelector(".tc-turn-fold");
const summaryBtn = () =>
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-turn-summary"]');
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");
});
it("toggles open on summary click", () => {
renderTurn(SETTLED);
flushSync(() => summaryBtn()!.click());
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("renders a live turn expanded with no summary line", () => {
renderTurn({ ...SETTLED, settled: false });
expect(summaryBtn()).toBeNull();
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("folds when the item settles while mounted", () => {
renderTurn({ ...SETTLED, settled: false });
renderTurn({ ...SETTLED, animateFold: true });
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
});