Files
multica/packages/views/chat/lib/copy-text.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

55 lines
2.1 KiB
TypeScript

import type { ChatMessage } from "@multica/core/types";
import type { ChatTimelineItem } from "@multica/core/chat";
/**
* Split an assistant timeline into three regions for the conductor-style fold:
* preface — text items before the first thinking/tool/error item
* middle — everything from the first to the last non-text item (inclusive),
* including any text items sandwiched between them
* final — text items after the last non-text item
*
* UI renders preface above the outer fold, middle inside the fold (with each
* row keeping its existing inner Collapsible), and final below the fold.
* Copy concatenates preface + final — the fold's contents are intentionally
* omitted, mirroring what's visible when the fold is closed.
*/
export function splitTimeline(items: ChatTimelineItem[]): {
preface: ChatTimelineItem[];
middle: ChatTimelineItem[];
final: ChatTimelineItem[];
} {
const firstNonTextIdx = items.findIndex((i) => i.type !== "text");
if (firstNonTextIdx === -1) {
return { preface: [], middle: [], final: items };
}
let lastNonTextIdx = items.length - 1;
while (lastNonTextIdx >= 0 && items[lastNonTextIdx]!.type === "text") {
lastNonTextIdx--;
}
return {
preface: items.slice(0, firstNonTextIdx),
middle: items.slice(firstNonTextIdx, lastNonTextIdx + 1),
final: items.slice(lastNonTextIdx + 1),
};
}
/**
* Markdown source the Copy action puts on the clipboard. By design this is
* the user-visible answer only — anything inside the outer fold (thinking,
* tool calls, sandwiched intermediate text) is dropped. Falls back to
* `message.content` for legacy messages without a timeline and for the
* pathological all-non-text shape so Copy never produces an empty string.
*/
export function extractCopyText(
message: ChatMessage,
timeline: ChatTimelineItem[],
): string {
if (timeline.length === 0) return message.content ?? "";
const { preface, final } = splitTimeline(timeline);
const pieces = [...preface, ...final]
.map((i) => i.content ?? "")
.filter((s) => s.length > 0);
if (pieces.length === 0) return message.content ?? "";
return pieces.join("\n\n");
}