mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 03:55:27 +02:00
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>
55 lines
2.1 KiB
TypeScript
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");
|
|
}
|