Files
multica/packages/views/chat/lib/copy-text.test.ts
Naiyuan Qing c784a6a9ee feat(chat): copy assistant reply + collapse process into a single outer fold (#2151)
Restructures the assistant timeline into a Conductor-style "X steps"
outer fold that wraps every thinking/tool/intermediate-text item between
the first and last non-text item; the final answer renders below the
fold at full prose size. The inner per-row Collapsibles
(ThinkingRow / ToolCallRow / ToolResultRow) are unchanged.

Adds an inline footer "Replied in 38s · [Copy]" beneath each persisted
assistant reply. Copy puts the markdown source of the visible text
(preface + final, never middle) on the clipboard via the existing
`copyMarkdown` helper. Suppressed during streaming.

Pure carving + extraction lives in `chat/lib/copy-text.ts` with 11 unit
tests covering all timeline shapes (all-text, all-non-text, standard,
preface, multi-final, legacy fallback).

Also cleans up 7 pre-existing `text-[11px]` arbitrary values in this
file to `text-xs`, and uses standard `size="icon-xs"` Button variant
for the Copy button (no manual size overrides).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 18:19:34 +08:00

135 lines
3.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import type { ChatMessage } from "@multica/core/types";
import type { ChatTimelineItem } from "@multica/core/chat";
import { splitTimeline, extractCopyText } from "./copy-text";
const text = (seq: number, content: string): ChatTimelineItem => ({
seq,
type: "text",
content,
});
const thinking = (seq: number, content = "..."): ChatTimelineItem => ({
seq,
type: "thinking",
content,
});
const tool = (seq: number, name = "Read"): ChatTimelineItem => ({
seq,
type: "tool_use",
tool: name,
input: { path: "/x" },
});
const message = (content: string): ChatMessage => ({
id: "m1",
chat_session_id: "s1",
role: "assistant",
content,
task_id: "t1",
created_at: "2026-05-06T00:00:00Z",
});
describe("splitTimeline", () => {
it("treats an all-text timeline as final (no fold)", () => {
const items = [text(1, "hello"), text(2, "world")];
expect(splitTimeline(items)).toEqual({
preface: [],
middle: [],
final: items,
});
});
it("treats an all-non-text timeline as middle with no final", () => {
const items = [thinking(1), tool(2), thinking(3)];
const out = splitTimeline(items);
expect(out.preface).toEqual([]);
expect(out.middle).toEqual(items);
expect(out.final).toEqual([]);
});
it("standard shape: thinking → tool → text → tool → final-text", () => {
const t1 = thinking(1);
const u1 = tool(2);
const x1 = text(3, "intermediate");
const u2 = tool(4);
const f1 = text(5, "final answer");
const out = splitTimeline([t1, u1, x1, u2, f1]);
expect(out.preface).toEqual([]);
expect(out.middle).toEqual([t1, u1, x1, u2]);
expect(out.final).toEqual([f1]);
});
it("collects multiple trailing text segments into final", () => {
const u = tool(1);
const f1 = text(2, "para 1");
const f2 = text(3, "para 2");
const out = splitTimeline([u, f1, f2]);
expect(out.middle).toEqual([u]);
expect(out.final).toEqual([f1, f2]);
});
it("collects leading text into preface", () => {
const p = text(1, "preface");
const u = tool(2);
const f = text(3, "final");
const out = splitTimeline([p, u, f]);
expect(out.preface).toEqual([p]);
expect(out.middle).toEqual([u]);
expect(out.final).toEqual([f]);
});
});
describe("extractCopyText", () => {
it("falls back to message.content when timeline is empty (legacy)", () => {
expect(extractCopyText(message("legacy body"), [])).toBe("legacy body");
});
it("returns concatenated text segments for an all-text timeline", () => {
expect(
extractCopyText(message(""), [text(1, "hello"), text(2, "world")]),
).toBe("hello\n\nworld");
});
it("returns only the final text for the standard tool-using shape", () => {
expect(
extractCopyText(message(""), [
thinking(1),
tool(2),
text(3, "intermediate — should be excluded"),
tool(4),
text(5, "final answer"),
]),
).toBe("final answer");
});
it("includes preface and final, excludes middle text", () => {
expect(
extractCopyText(message(""), [
text(1, "preface"),
tool(2),
text(3, "middle — excluded"),
tool(4),
text(5, "final"),
]),
).toBe("preface\n\nfinal");
});
it("falls back to message.content when timeline has no text items", () => {
expect(
extractCopyText(message("fallback body"), [thinking(1), tool(2)]),
).toBe("fallback body");
});
it("joins multiple trailing text segments with blank-line separators", () => {
expect(
extractCopyText(message(""), [
tool(1),
text(2, "para 1"),
text(3, "para 2"),
]),
).toBe("para 1\n\npara 2");
});
});