mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
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>
110 lines
4.0 KiB
TypeScript
110 lines
4.0 KiB
TypeScript
// @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");
|
||
});
|
||
});
|