+
);
})()}
-
- {formatMessageMeta(
- row.message.createdAt,
- row.message.streaming
- ? formatElapsed(row.durationStart, nowIso)
- : formatElapsed(row.durationStart, row.message.completedAt),
- timestampFormat,
- )}
-
+
+
+ {showAssistantCopyButton ? (
+
+ resolveAssistantMessageCopyText(
+ row.message.text,
+ assistantResponseCopyFormat,
+ )
+ }
+ label="Copy response"
+ />
+ ) : null}
+
+
+ {formatMessageMeta(
+ row.message.createdAt,
+ row.message.streaming
+ ? formatElapsed(row.durationStart, nowIso)
+ : formatElapsed(row.durationStart, row.message.completedAt),
+ timestampFormat,
+ )}
+
+
>
);
diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts
index 9b1331a9d6..9fb9f41412 100644
--- a/apps/web/src/components/timelineHeight.test.ts
+++ b/apps/web/src/components/timelineHeight.test.ts
@@ -10,10 +10,34 @@ describe("estimateTimelineMessageHeight", () => {
estimateTimelineMessageHeight({
role: "assistant",
text: "a".repeat(144),
+ streaming: false,
+ }),
+ ).toBe(140);
+ });
+
+ it("keeps the smaller assistant base height while streaming", () => {
+ expect(
+ estimateTimelineMessageHeight({
+ role: "assistant",
+ text: "a".repeat(144),
+ streaming: true,
}),
).toBe(122);
});
+ it("keeps the smaller assistant base height when plain-text copy resolves empty", () => {
+ expect(
+ estimateTimelineMessageHeight(
+ {
+ role: "assistant",
+ text: "---",
+ streaming: false,
+ },
+ { timelineWidthPx: null, assistantResponseCopyFormat: "plain-text" },
+ ),
+ ).toBe(100);
+ });
+
it("uses assistant sizing rules for system messages", () => {
expect(
estimateTimelineMessageHeight({
@@ -130,9 +154,10 @@ describe("estimateTimelineMessageHeight", () => {
const message = {
role: "assistant" as const,
text: "a".repeat(200),
+ streaming: false,
};
- expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188);
- expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122);
+ expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(206);
+ expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(140);
});
});
diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts
index 998a2a0b7f..69dda9fb37 100644
--- a/apps/web/src/components/timelineHeight.ts
+++ b/apps/web/src/components/timelineHeight.ts
@@ -1,10 +1,13 @@
+import { type AssistantResponseCopyFormat } from "@t3tools/contracts/settings";
import { deriveDisplayedUserMessageState } from "../lib/terminalContext";
+import { hasAssistantResponseCopyText } from "../lib/assistantMessageCopy";
import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts";
const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72;
const USER_CHARS_PER_LINE_FALLBACK = 56;
const LINE_HEIGHT_PX = 22;
const ASSISTANT_BASE_HEIGHT_PX = 78;
+const ASSISTANT_COMPLETED_ACTION_BASE_HEIGHT_PX = 96;
const USER_BASE_HEIGHT_PX = 96;
const ATTACHMENTS_PER_ROW = 2;
// Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap.
@@ -20,11 +23,13 @@ const MIN_ASSISTANT_CHARS_PER_LINE = 20;
interface TimelineMessageHeightInput {
role: "user" | "assistant" | "system";
text: string;
+ streaming?: boolean;
attachments?: ReadonlyArray<{ id: string }>;
}
interface TimelineHeightEstimateLayout {
timelineWidthPx: number | null;
+ assistantResponseCopyFormat?: AssistantResponseCopyFormat;
}
function estimateWrappedLineCount(text: string, charsPerLine: number): number {
@@ -73,7 +78,12 @@ export function estimateTimelineMessageHeight(
if (message.role === "assistant") {
const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);
- return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX;
+ const assistantBaseHeightPx =
+ message.streaming !== true &&
+ hasAssistantResponseCopyText(message.text, layout.assistantResponseCopyFormat ?? "markdown")
+ ? ASSISTANT_COMPLETED_ACTION_BASE_HEIGHT_PX
+ : ASSISTANT_BASE_HEIGHT_PX;
+ return assistantBaseHeightPx + estimatedLines * LINE_HEIGHT_PX;
}
if (message.role === "user") {
diff --git a/apps/web/src/lib/assistantMessageCopy.test.ts b/apps/web/src/lib/assistantMessageCopy.test.ts
new file mode 100644
index 0000000000..b7f0bcfaf4
--- /dev/null
+++ b/apps/web/src/lib/assistantMessageCopy.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ hasAssistantResponseCopyText,
+ markdownToPlainText,
+ resolveAssistantMessageCopyText,
+} from "./assistantMessageCopy";
+
+describe("assistantMessageCopy", () => {
+ it("returns the raw assistant markdown unchanged in markdown mode", () => {
+ const markdown = ["# Heading", "", "- item", "", "```ts", "console.log('hi');", "```"].join(
+ "\n",
+ );
+
+ expect(resolveAssistantMessageCopyText(markdown, "markdown")).toBe(markdown);
+ });
+
+ it("serializes markdown into stable plain text", () => {
+ const markdown = [
+ "# Heading",
+ "",
+ "Paragraph with [docs](https://example.com/docs) and [](https://example.com/fallback).",
+ "",
+ "> Quoted **text**",
+ "",
+ "- first item",
+ "- second item",
+ "",
+ "1. ordered",
+ "2. next",
+ "",
+ "| Name | Value |",
+ "| --- | --- |",
+ "| One | 1 |",
+ "",
+ "```ts",
+ "const value = 1;",
+ "console.log(value);",
+ "```",
+ ].join("\n");
+
+ expect(markdownToPlainText(markdown)).toBe(
+ [
+ "Heading",
+ "",
+ "Paragraph with docs and https://example.com/fallback.",
+ "",
+ "Quoted text",
+ "",
+ "- first item",
+ "- second item",
+ "",
+ "1. ordered",
+ "2. next",
+ "",
+ "Name | Value",
+ "One | 1",
+ "",
+ "const value = 1;",
+ "console.log(value);",
+ ].join("\n"),
+ );
+ });
+
+ it("aligns continuation blocks for ordered lists with wide markers", () => {
+ const markdown = [
+ "10. first paragraph",
+ "",
+ " ```ts",
+ " const value = 1;",
+ " ```",
+ ].join("\n");
+
+ expect(markdownToPlainText(markdown)).toBe(
+ ["10. first paragraph", " const value = 1;"].join("\n"),
+ );
+ });
+
+ it("preserves gfm task state in copied plain text", () => {
+ const markdown = ["- [x] done", "- [ ] todo", "", "1. [x] ship it"].join("\n");
+
+ expect(markdownToPlainText(markdown)).toBe(
+ ["- [x] done", "- [ ] todo", "", "1. [x] ship it"].join("\n"),
+ );
+ });
+
+ it("preserves visible raw html text in plain-text mode", () => {
+ const markdown = ["
", "Example
", " "].join("\n");
+
+ expect(markdownToPlainText(markdown)).toBe(markdown);
+ expect(hasAssistantResponseCopyText(markdown, "plain-text")).toBe(true);
+ });
+
+ it("preserves leading indentation for top-level code blocks", () => {
+ const markdown = ["```py", " print('hi')", "```"].join("\n");
+
+ expect(markdownToPlainText(markdown)).toBe(" print('hi')");
+ });
+
+ it("collapses soft-wrapped paragraph newlines but preserves explicit breaks", () => {
+ const markdown = ["soft", "wrap", "", "hard ", "break"].join("\n");
+
+ expect(markdownToPlainText(markdown)).toBe(["soft wrap", "", "hard", "break"].join("\n"));
+ });
+});
diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts
new file mode 100644
index 0000000000..60927595e2
--- /dev/null
+++ b/apps/web/src/lib/assistantMessageCopy.ts
@@ -0,0 +1,203 @@
+import { type AssistantResponseCopyFormat } from "@t3tools/contracts/settings";
+import remarkGfm from "remark-gfm";
+import remarkParse from "remark-parse";
+import { unified } from "unified";
+import { LRUCache } from "./lruCache";
+
+type MarkdownNode = {
+ type: string;
+ value?: string;
+ alt?: string;
+ url?: string;
+ ordered?: boolean;
+ start?: number | null;
+ checked?: boolean | null;
+ children?: MarkdownNode[];
+};
+
+const markdownProcessor = unified().use(remarkParse).use(remarkGfm);
+const MAX_PLAIN_TEXT_CACHE_ENTRIES = 500;
+const MAX_PLAIN_TEXT_CACHE_MEMORY_BYTES = 5 * 1024 * 1024;
+const plainTextCache = new LRUCache
(
+ MAX_PLAIN_TEXT_CACHE_ENTRIES,
+ MAX_PLAIN_TEXT_CACHE_MEMORY_BYTES,
+);
+
+export function resolveAssistantMessageCopyText(
+ messageText: string,
+ format: AssistantResponseCopyFormat,
+): string {
+ if (format === "markdown") {
+ return messageText;
+ }
+
+ return getCachedPlainText(messageText);
+}
+
+export function hasAssistantResponseCopyText(
+ messageText: string,
+ format: AssistantResponseCopyFormat,
+): boolean {
+ if (messageText.trim().length === 0) {
+ return false;
+ }
+
+ return resolveAssistantMessageCopyText(messageText, format).trim().length > 0;
+}
+
+export function markdownToPlainText(markdown: string): string {
+ const normalizedMarkdown = markdown.replace(/\r\n/g, "\n");
+ if (normalizedMarkdown.trim().length === 0) {
+ return "";
+ }
+
+ const tree = markdownProcessor.parse(normalizedMarkdown) as MarkdownNode;
+ return normalizePlainText(renderBlockChildren(tree.children ?? []));
+}
+
+function renderBlockChildren(nodes: readonly MarkdownNode[]): string {
+ return nodes
+ .map((node) => renderBlock(node))
+ .filter((block) => block.length > 0)
+ .join("\n\n");
+}
+
+function renderBlock(node: MarkdownNode): string {
+ switch (node.type) {
+ case "root":
+ case "blockquote":
+ return renderBlockChildren(node.children ?? []);
+ case "paragraph":
+ case "heading":
+ return normalizeInlineText(renderInlineChildren(node.children ?? []));
+ case "code":
+ return normalizeCodeBlock(node.value ?? "");
+ case "list":
+ return renderList(node);
+ case "table":
+ return renderTable(node);
+ case "html":
+ return normalizeCodeBlock(node.value ?? "");
+ case "thematicBreak":
+ return "";
+ default:
+ return normalizeInlineText(renderInline(node));
+ }
+}
+
+function renderList(node: MarkdownNode): string {
+ const items = node.children ?? [];
+ const ordered = Boolean(node.ordered);
+ const start = typeof node.start === "number" ? node.start : 1;
+
+ return items
+ .map((item, index) => renderListItem(item, ordered ? `${start + index}. ` : "- "))
+ .filter((item) => item.length > 0)
+ .join("\n");
+}
+
+function renderListItem(node: MarkdownNode, marker: string): string {
+ const blocks = (node.children ?? [])
+ .map((child) => renderBlock(child))
+ .filter((block) => block.length > 0);
+ const taskPrefix = node.checked === true ? "[x] " : node.checked === false ? "[ ] " : "";
+ const contentPrefix = `${marker}${taskPrefix}`;
+
+ if (blocks.length === 0) {
+ return contentPrefix.trimEnd();
+ }
+
+ const continuationPrefix = " ".repeat(contentPrefix.length);
+ const firstBlock = blocks[0]!;
+ const remainingBlocks = blocks.slice(1);
+ const firstBlockLines = splitLines(firstBlock);
+ const lines = firstBlockLines.map((line, index) =>
+ index === 0 ? `${contentPrefix}${line}` : `${continuationPrefix}${line}`,
+ );
+
+ for (const block of remainingBlocks) {
+ for (const line of splitLines(block)) {
+ lines.push(`${continuationPrefix}${line}`);
+ }
+ }
+
+ return lines.join("\n");
+}
+
+function renderTable(node: MarkdownNode): string {
+ return (node.children ?? [])
+ .map((row) =>
+ (row.children ?? [])
+ .map((cell) => normalizeInlineText(renderInlineChildren(cell.children ?? [])))
+ .join(" | "),
+ )
+ .filter((row) => row.length > 0)
+ .join("\n");
+}
+
+function renderInlineChildren(nodes: readonly MarkdownNode[]): string {
+ return nodes.map((node) => renderInline(node)).join("");
+}
+
+function renderInline(node: MarkdownNode): string {
+ switch (node.type) {
+ case "text":
+ return (node.value ?? "").replace(/\r?\n+/g, " ");
+ case "inlineCode":
+ case "html":
+ return node.value ?? "";
+ case "break":
+ return "\n";
+ case "link": {
+ const label = normalizeInlineText(renderInlineChildren(node.children ?? []));
+ if (label.length > 0) {
+ return label;
+ }
+ return typeof node.url === "string" ? node.url : "";
+ }
+ case "image":
+ return node.alt?.trim() || node.url || "";
+ case "delete":
+ case "emphasis":
+ case "strong":
+ case "paragraph":
+ case "heading":
+ return renderInlineChildren(node.children ?? []);
+ default:
+ return renderInlineChildren(node.children ?? []);
+ }
+}
+
+function normalizeCodeBlock(value: string): string {
+ return value.replace(/\r\n/g, "\n").replace(/\n+$/g, "");
+}
+
+function normalizeInlineText(value: string): string {
+ return value
+ .replace(/[ \t]+\n/g, "\n")
+ .replace(/\n[ \t]+/g, "\n")
+ .replace(/[ \t]{2,}/g, " ")
+ .trim();
+}
+
+function normalizePlainText(value: string): string {
+ return value
+ .replace(/[ \t]+\n/g, "\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .replace(/\n+$/g, "");
+}
+
+function splitLines(value: string): string[] {
+ return value.replace(/\r\n/g, "\n").split("\n");
+}
+
+function getCachedPlainText(markdown: string): string {
+ const cached = plainTextCache.get(markdown);
+ if (cached !== null) {
+ return cached;
+ }
+
+ const plainText = markdownToPlainText(markdown);
+ plainTextCache.set(markdown, plainText, plainText.length * 2);
+ return plainText;
+}
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx
index 3e92891a54..9fc82226f6 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -75,6 +75,11 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;
+const ASSISTANT_RESPONSE_COPY_FORMAT_LABELS = {
+ markdown: "Raw markdown",
+ "plain-text": "Rendered plain text",
+} as const;
+
const EMPTY_SERVER_PROVIDERS: ReadonlyArray = [];
type InstallProviderSettings = {
@@ -361,6 +366,10 @@ function SettingsRouteView() {
);
const changedSettingLabels = [
...(theme !== "system" ? ["Theme"] : []),
+ ...(settings.assistantResponseCopyFormat !==
+ DEFAULT_UNIFIED_SETTINGS.assistantResponseCopyFormat
+ ? ["Assistant copy format"]
+ : []),
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
@@ -638,6 +647,54 @@ function SettingsRouteView() {
}
/>
+
+ updateSettings({
+ assistantResponseCopyFormat:
+ DEFAULT_UNIFIED_SETTINGS.assistantResponseCopyFormat,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ />
+
DEFAULT_ASSISTANT_RESPONSE_COPY_FORMAT),
+ ),
confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)),
diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),
sidebarProjectSortOrder: SidebarProjectSortOrder.pipe(