From ee80eb6417f213ca80ad646cf9d4bf8a84c60776 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 21:10:16 -0300 Subject: [PATCH 1/8] Add assistant response copy action --- apps/web/package.json | 2 + apps/web/src/components/ChatView.browser.tsx | 186 +++++++++++++++ apps/web/src/components/ChatView.tsx | 2 + .../src/components/chat/MessageCopyButton.tsx | 13 +- .../components/chat/MessagesTimeline.test.tsx | 218 +++++++++++------- .../src/components/chat/MessagesTimeline.tsx | 43 +++- apps/web/src/lib/assistantMessageCopy.test.ts | 60 +++++ apps/web/src/lib/assistantMessageCopy.ts | 168 ++++++++++++++ apps/web/src/routes/_chat.settings.tsx | 57 +++++ bun.lock | 2 + packages/contracts/src/settings.ts | 7 + 11 files changed, 659 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/lib/assistantMessageCopy.test.ts create mode 100644 apps/web/src/lib/assistantMessageCopy.ts diff --git a/apps/web/package.json b/apps/web/package.json index 5127faf827..1bf5ff1afa 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,7 +38,9 @@ "react-dom": "^19.0.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", "tailwind-merge": "^3.4.0", + "unified": "^11.0.5", "zustand": "^5.0.11" }, "devDependencies": { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 7bf3ecf26c..1a0e820934 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -39,6 +39,7 @@ const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; +const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; interface WsRequestEnvelope { id: string; @@ -278,6 +279,29 @@ function createSnapshotForTargetUser(options: { }; } +function createSnapshotForTargetAssistantMessage(options: { + targetAssistantId: MessageId; + targetText: string; +}): OrchestrationReadModel { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-assistant-copy-target" as MessageId, + targetText: "assistant copy target", + }); + + return { + ...snapshot, + threads: snapshot.threads.map((thread) => + Object.assign({}, thread, { + messages: thread.messages.map((message) => + message.id === options.targetAssistantId + ? Object.assign({}, message, { text: options.targetText }) + : message, + ), + }), + ), + }; +} + function buildFixture(snapshot: OrchestrationReadModel): TestFixture { return { snapshot, @@ -619,6 +643,25 @@ async function waitForSendButton(): Promise { ); } +function installClipboardWriteTextSpy() { + const writeText = vi.fn<(value: string) => Promise>().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + return writeText; +} + +function setAssistantResponseCopyFormat(format: "markdown" | "plain-text"): void { + localStorage.setItem( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + assistantResponseCopyFormat: format, + }), + ); +} + async function waitForInteractionModeButton( expectedLabel: "Chat" | "Plan", ): Promise { @@ -1228,6 +1271,149 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("copies the raw assistant markdown by default", async () => { + const assistantMessageId = "msg-assistant-21" as MessageId; + const assistantText = [ + "# Copy me", + "", + "Paragraph with [docs](https://example.com/docs).", + "", + "```ts", + "const value = 1;", + "```", + ].join("\n"); + const writeText = installClipboardWriteTextSpy(); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetAssistantMessage({ + targetAssistantId: assistantMessageId, + targetText: assistantText, + }), + }); + + try { + const assistantRow = await waitForElement( + () => + document.querySelector( + `[data-message-id="${assistantMessageId}"][data-message-role="assistant"]`, + ), + "Unable to find assistant response row.", + ); + const copyButton = await waitForElement( + () => assistantRow.querySelector('button[aria-label="Copy response"]'), + "Unable to find assistant copy button.", + ); + + copyButton.click(); + + await vi.waitFor( + () => { + expect(writeText).toHaveBeenCalledWith(assistantText); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("copies assistant responses as plain text when the setting is enabled", async () => { + const assistantMessageId = "msg-assistant-21" as MessageId; + const assistantText = [ + "# Copy me", + "", + "Paragraph with [docs](https://example.com/docs).", + "", + "```ts", + "const value = 1;", + "```", + ].join("\n"); + const writeText = installClipboardWriteTextSpy(); + setAssistantResponseCopyFormat("plain-text"); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetAssistantMessage({ + targetAssistantId: assistantMessageId, + targetText: assistantText, + }), + }); + + try { + const assistantRow = await waitForElement( + () => + document.querySelector( + `[data-message-id="${assistantMessageId}"][data-message-role="assistant"]`, + ), + "Unable to find assistant response row.", + ); + const copyButton = await waitForElement( + () => assistantRow.querySelector('button[aria-label="Copy response"]'), + "Unable to find assistant copy button.", + ); + + copyButton.click(); + + await vi.waitFor( + () => { + expect(writeText).toHaveBeenCalledWith( + ["Copy me", "", "Paragraph with docs.", "", "const value = 1;"].join("\n"), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps markdown code-block copy scoped to the code block", async () => { + const assistantMessageId = "msg-assistant-21" as MessageId; + const assistantText = [ + "# Copy me", + "", + "Paragraph with [docs](https://example.com/docs).", + "", + "```ts", + "const value = 1;", + "```", + ].join("\n"); + const writeText = installClipboardWriteTextSpy(); + setAssistantResponseCopyFormat("plain-text"); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetAssistantMessage({ + targetAssistantId: assistantMessageId, + targetText: assistantText, + }), + }); + + try { + const assistantRow = await waitForElement( + () => + document.querySelector( + `[data-message-id="${assistantMessageId}"][data-message-role="assistant"]`, + ), + "Unable to find assistant response row.", + ); + const codeCopyButton = await waitForElement( + () => assistantRow.querySelector('button[aria-label="Copy code"]'), + "Unable to find code-block copy button.", + ); + + codeCopyButton.click(); + + await vi.waitFor( + () => { + expect(writeText).toHaveBeenCalled(); + expect(writeText.mock.calls.at(-1)?.[0].trim()).toBe("const value = 1;"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("runs project scripts from local draft threads at the project cwd", async () => { useComposerDraftStore.setState({ draftThreadsByThreadId: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d926bf308..42c0758b61 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -257,6 +257,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; + const assistantResponseCopyFormat = settings.assistantResponseCopyFormat; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, @@ -3634,6 +3635,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} + assistantResponseCopyFormat={assistantResponseCopyFormat} timestampFormat={timestampFormat} workspaceRoot={activeProject?.cwd ?? undefined} /> diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index cf1e798912..42b9a4b087 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -3,7 +3,13 @@ import { CopyIcon, CheckIcon } from "lucide-react"; import { Button } from "../ui/button"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { +export const MessageCopyButton = memo(function MessageCopyButton({ + text, + label = "Copy message", +}: { + text: string | (() => string); + label?: string; +}) { const { copyToClipboard, isCopied } = useCopyToClipboard(); return ( @@ -11,8 +17,9 @@ export const MessageCopyButton = memo(function MessageCopyButton({ text }: { tex type="button" size="xs" variant="outline" - onClick={() => copyToClipboard(text)} - title="Copy message" + onClick={() => copyToClipboard(typeof text === "function" ? text() : text)} + title={isCopied ? label.replace(/^Copy\s+/i, "Copied ") : label} + aria-label={isCopied ? label.replace(/^Copy\s+/i, "Copied ") : label} > {isCopied ? : } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..bca95fe9ef 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,6 +1,15 @@ import { MessageId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import { deriveTimelineEntries } from "../../session-logic"; + +vi.mock("../../hooks/useTheme", () => ({ + useTheme: () => ({ + theme: "light", + resolvedTheme: "light", + setTheme: () => {}, + }), +})); function matchMedia() { return { @@ -42,55 +51,60 @@ beforeAll(() => { }); }); +async function renderTimeline(timelineEntries: ReturnType) { + const { MessagesTimeline } = await import("./MessagesTimeline"); + return renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + assistantResponseCopyFormat="markdown" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); +} + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - ", - "- Terminal 1 lines 1-5:", - " 1 | julius@mac effect-http-ws-cli % bun i", - " 2 | bun install v1.3.9 (cf6cdbbb)", - "", - ].join("\n"), - createdAt: "2026-03-17T19:12:28.000Z", - streaming: false, - }, - }, - ]} - completionDividerBeforeEntryId={null} - completionSummary={null} - turnDiffSummaryByAssistantMessageId={new Map()} - nowIso="2026-03-17T19:12:30.000Z" - expandedWorkGroups={{}} - onToggleWorkGroup={() => {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - />, - ); + const markup = await renderTimeline([ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("message-2"), + role: "user", + text: [ + "yoo what's @terminal-1:1-5 mean", + "", + "", + "- Terminal 1 lines 1-5:", + " 1 | julius@mac effect-http-ws-cli % bun i", + " 2 | bun install v1.3.9 (cf6cdbbb)", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ]); expect(markup).toContain("Terminal 1 lines 1-5"); expect(markup).toContain("lucide-terminal"); @@ -98,46 +112,80 @@ describe("MessagesTimeline", () => { }); it("renders context compaction entries in the normal work log", async () => { - const { MessagesTimeline } = await import("./MessagesTimeline"); - const markup = renderToStaticMarkup( - {}} - onOpenTurnDiff={() => {}} - revertTurnCountByUserMessageId={new Map()} - onRevertUserMessage={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - />, - ); + const markup = await renderTimeline([ + { + id: "entry-1", + kind: "work", + createdAt: "2026-03-17T19:12:28.000Z", + entry: { + id: "work-1", + createdAt: "2026-03-17T19:12:28.000Z", + label: "Context compacted", + tone: "info", + }, + }, + ]); expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("renders a copy control for completed assistant messages", async () => { + const markup = await renderTimeline([ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-complete"), + role: "assistant", + text: "Completed response", + createdAt: "2026-03-17T19:12:28.000Z", + completedAt: "2026-03-17T19:12:30.000Z", + streaming: false, + }, + }, + ]); + + expect(markup).toContain("Copy response"); + }); + + it("does not render a copy control for streaming assistant messages", async () => { + const markup = await renderTimeline([ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-streaming"), + role: "assistant", + text: "Partial response", + createdAt: "2026-03-17T19:12:28.000Z", + streaming: true, + }, + }, + ]); + + expect(markup).not.toContain("Copy response"); + }); + + it("does not render a copy control for empty completed assistant messages", async () => { + const markup = await renderTimeline([ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-empty"), + role: "assistant", + text: " ", + createdAt: "2026-03-17T19:12:28.000Z", + completedAt: "2026-03-17T19:12:30.000Z", + streaming: false, + }, + }, + ]); + + expect(markup).not.toContain("Copy response"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3174030ef..1c8d72e95e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -48,13 +48,17 @@ import { type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; -import { type TimestampFormat } from "@t3tools/contracts/settings"; +import { + type AssistantResponseCopyFormat, + type TimestampFormat, +} from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; import { buildInlineTerminalContextText, formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { resolveAssistantMessageCopyText } from "../../lib/assistantMessageCopy"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -79,6 +83,7 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; + assistantResponseCopyFormat: AssistantResponseCopyFormat; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; } @@ -103,6 +108,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, markdownCwd, resolvedTheme, + assistantResponseCopyFormat, timestampFormat, workspaceRoot, }: MessagesTimelineProps) { @@ -448,7 +454,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )} -
+
); })()} -

- {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.durationStart, nowIso) - : formatElapsed(row.durationStart, row.message.completedAt), - timestampFormat, - )} -

+
+
+ {!row.message.streaming && row.message.text.trim().length > 0 ? ( + + 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/lib/assistantMessageCopy.test.ts b/apps/web/src/lib/assistantMessageCopy.test.ts new file mode 100644 index 0000000000..a588b0c5cc --- /dev/null +++ b/apps/web/src/lib/assistantMessageCopy.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { 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"), + ); + }); +}); diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts new file mode 100644 index 0000000000..2960823baf --- /dev/null +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -0,0 +1,168 @@ +import { type AssistantResponseCopyFormat } from "@t3tools/contracts/settings"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +type MarkdownNode = { + type: string; + value?: string; + alt?: string; + url?: string; + ordered?: boolean; + start?: number | null; + children?: MarkdownNode[]; +}; + +const markdownProcessor = unified().use(remarkParse).use(remarkGfm); + +export function resolveAssistantMessageCopyText( + messageText: string, + format: AssistantResponseCopyFormat, +): string { + if (format === "markdown") { + return messageText; + } + + return markdownToPlainText(messageText); +} + +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": + 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); + + if (blocks.length === 0) { + return marker.trimEnd(); + } + + const continuationPrefix = " ".repeat(marker.length); + const firstBlock = blocks[0]!; + const remainingBlocks = blocks.slice(1); + const firstBlockLines = splitLines(firstBlock); + const lines = firstBlockLines.map((line, index) => + index === 0 ? `${marker}${line}` : `${continuationPrefix}${line}`, + ); + + for (const block of remainingBlocks) { + for (const line of splitLines(block)) { + lines.push(` ${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": + case "inlineCode": + 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") + .trim(); +} + +function splitLines(value: string): string[] { + return value.replace(/\r\n/g, "\n").split("\n"); +} 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( From 00f34deb7854dd9c3608413f51841c3d1d4f23d2 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 21:17:44 -0300 Subject: [PATCH 2/8] Fix ordered list copy indentation --- apps/web/src/lib/assistantMessageCopy.test.ts | 14 ++++++++++++++ apps/web/src/lib/assistantMessageCopy.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/assistantMessageCopy.test.ts b/apps/web/src/lib/assistantMessageCopy.test.ts index a588b0c5cc..0d3de5db6b 100644 --- a/apps/web/src/lib/assistantMessageCopy.test.ts +++ b/apps/web/src/lib/assistantMessageCopy.test.ts @@ -57,4 +57,18 @@ describe("assistantMessageCopy", () => { ].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"), + ); + }); }); diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts index 2960823baf..3ae810a945 100644 --- a/apps/web/src/lib/assistantMessageCopy.ts +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -95,7 +95,7 @@ function renderListItem(node: MarkdownNode, marker: string): string { for (const block of remainingBlocks) { for (const line of splitLines(block)) { - lines.push(` ${line}`); + lines.push(`${continuationPrefix}${line}`); } } From 45a53c262a7034a85e54c003ec4d3d4c31e19e23 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 21:55:23 -0300 Subject: [PATCH 3/8] Refine assistant copy footer behavior --- apps/web/src/components/ChatView.browser.tsx | 39 +++++++++++-------- .../components/chat/MessagesTimeline.test.tsx | 30 +++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 27 ++++++++----- .../web/src/components/timelineHeight.test.ts | 16 +++++++- apps/web/src/components/timelineHeight.ts | 8 +++- 5 files changed, 89 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1a0e820934..e9a2276fd3 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -87,7 +87,7 @@ const ATTACHMENT_VIEWPORT_MATRIX = [ { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, ] as const satisfies readonly ViewportSpec[]; -interface UserRowMeasurement { +interface MessageRowMeasurement { measuredRowHeightPx: number; timelineWidthMeasuredPx: number; renderedInVirtualizedRegion: boolean; @@ -96,7 +96,10 @@ interface UserRowMeasurement { interface MountedChatView { [Symbol.asyncDispose]: () => Promise; cleanup: () => Promise; - measureUserRow: (targetMessageId: MessageId) => Promise; + measureMessageRow: ( + targetMessageId: MessageId, + role: "user" | "assistant", + ) => Promise; setViewport: (viewport: ViewportSpec) => Promise; router: ReturnType; } @@ -747,12 +750,13 @@ async function waitForImagesToLoad(scope: ParentNode): Promise { await waitForLayout(); } -async function measureUserRow(options: { +async function measureMessageRow(options: { host: HTMLElement; targetMessageId: MessageId; -}): Promise { - const { host, targetMessageId } = options; - const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`; + role: "user" | "assistant"; +}): Promise { + const { host, targetMessageId, role } = options; + const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="${role}"]`; const scrollContainer = await waitForElement( () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), @@ -766,7 +770,7 @@ async function measureUserRow(options: { scrollContainer.dispatchEvent(new Event("scroll")); await waitForLayout(); row = host.querySelector(rowSelector); - expect(row, "Unable to locate targeted user message row.").toBeTruthy(); + expect(row, `Unable to locate targeted ${role} message row.`).toBeTruthy(); }, { timeout: 8_000, @@ -795,12 +799,14 @@ async function measureUserRow(options: { scrollContainer.dispatchEvent(new Event("scroll")); await nextFrame(); const measuredRow = host.querySelector(rowSelector); - expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); + expect(measuredRow, `Unable to measure targeted ${role} row height.`).toBeTruthy(); timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0); - expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); + expect(measuredRowHeightPx, `Unable to measure targeted ${role} row height.`).toBeGreaterThan( + 0, + ); }, { timeout: 4_000, @@ -853,7 +859,8 @@ async function mountChatView(options: { return { [Symbol.asyncDispose]: cleanup, cleanup, - measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), + measureMessageRow: async (targetMessageId: MessageId, role: "user" | "assistant") => + measureMessageRow({ host, targetMessageId, role }), setViewport: async (viewport: ViewportSpec) => { await setViewport(viewport); await waitForProductionStyles(); @@ -866,14 +873,14 @@ async function measureUserRowAtViewport(options: { snapshot: OrchestrationReadModel; targetMessageId: MessageId; viewport: ViewportSpec; -}): Promise { +}): Promise { const mounted = await mountChatView({ viewport: options.viewport, snapshot: options.snapshot, }); try { - return await mounted.measureUserRow(options.targetMessageId); + return await mounted.measureMessageRow(options.targetMessageId, "user"); } finally { await mounted.cleanup(); } @@ -940,7 +947,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await mounted.measureUserRow(targetMessageId); + await mounted.measureMessageRow(targetMessageId, "user"); expect(renderedInVirtualizedRegion).toBe(true); @@ -971,12 +978,12 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const measurements: Array< - UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } + MessageRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } > = []; for (const viewport of TEXT_VIEWPORT_MATRIX) { await mounted.setViewport(viewport); - const measurement = await mounted.measureUserRow(targetMessageId); + const measurement = await mounted.measureMessageRow(targetMessageId, "user"); const estimatedHeightPx = estimateTimelineMessageHeight( { role: "user", text: userText, attachments: [] }, { timelineWidthPx: measurement.timelineWidthMeasuredPx }, @@ -1060,7 +1067,7 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = - await mounted.measureUserRow(targetMessageId); + await mounted.measureMessageRow(targetMessageId, "user"); expect(renderedInVirtualizedRegion).toBe(true); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index bca95fe9ef..c7f28449ad 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -51,7 +51,10 @@ beforeAll(() => { }); }); -async function renderTimeline(timelineEntries: ReturnType) { +async function renderTimeline( + timelineEntries: ReturnType, + assistantResponseCopyFormat: "markdown" | "plain-text" = "markdown", +) { const { MessagesTimeline } = await import("./MessagesTimeline"); return renderToStaticMarkup( {}} markdownCwd={undefined} resolvedTheme="light" - assistantResponseCopyFormat="markdown" + assistantResponseCopyFormat={assistantResponseCopyFormat} timestampFormat="locale" workspaceRoot={undefined} />, @@ -188,4 +191,27 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("Copy response"); }); + + it("does not render a copy control when plain-text resolution is empty", async () => { + const markup = await renderTimeline( + [ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-plain-text-empty"), + role: "assistant", + text: "---", + createdAt: "2026-03-17T19:12:28.000Z", + completedAt: "2026-03-17T19:12:30.000Z", + streaming: false, + }, + }, + ], + "plain-text", + ); + + expect(markup).not.toContain("Copy response"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1c8d72e95e..f8cfffbbe8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -443,6 +443,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.role === "assistant" && (() => { const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + let assistantCopyText: string | null = null; + const getAssistantCopyText = () => { + if (assistantCopyText !== null) { + return assistantCopyText; + } + assistantCopyText = resolveAssistantMessageCopyText( + row.message.text, + assistantResponseCopyFormat, + ); + return assistantCopyText; + }; + const showAssistantCopyButton = + !row.message.streaming && + row.message.text.trim().length > 0 && + getAssistantCopyText().trim().length > 0; return ( <> {row.showCompletionDivider && ( @@ -518,16 +533,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ })()}
- {!row.message.streaming && row.message.text.trim().length > 0 ? ( - - resolveAssistantMessageCopyText( - row.message.text, - assistantResponseCopyFormat, - ) - } - label="Copy response" - /> + {showAssistantCopyButton ? ( + ) : null}

diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 9b1331a9d6..06f29236bb 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -10,6 +10,17 @@ 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); }); @@ -130,9 +141,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..454003969e 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -5,6 +5,7 @@ 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,6 +21,7 @@ const MIN_ASSISTANT_CHARS_PER_LINE = 20; interface TimelineMessageHeightInput { role: "user" | "assistant" | "system"; text: string; + streaming?: boolean; attachments?: ReadonlyArray<{ id: string }>; } @@ -73,7 +75,11 @@ 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 && message.text.trim().length > 0 + ? ASSISTANT_COMPLETED_ACTION_BASE_HEIGHT_PX + : ASSISTANT_BASE_HEIGHT_PX; + return assistantBaseHeightPx + estimatedLines * LINE_HEIGHT_PX; } if (message.role === "user") { From 484fcc552ffab31f64171f41130feda66fad93f4 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 22:33:38 -0300 Subject: [PATCH 4/8] Address assistant copy review feedback --- .../components/chat/MessagesTimeline.test.tsx | 23 +++++++++++ .../src/components/chat/MessagesTimeline.tsx | 34 ++++++++-------- .../web/src/components/timelineHeight.test.ts | 13 ++++++ apps/web/src/components/timelineHeight.ts | 6 ++- apps/web/src/lib/assistantMessageCopy.test.ts | 21 +++++++++- apps/web/src/lib/assistantMessageCopy.ts | 40 +++++++++++++++++-- 6 files changed, 115 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c7f28449ad..d443bf3265 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -214,4 +214,27 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("Copy response"); }); + + it("renders a copy control for html-only assistant messages in plain-text mode", async () => { + const markup = await renderTimeline( + [ + { + id: "entry-1", + kind: "message", + createdAt: "2026-03-17T19:12:28.000Z", + message: { + id: MessageId.makeUnsafe("assistant-html-only"), + role: "assistant", + text: "

Example
", + createdAt: "2026-03-17T19:12:28.000Z", + completedAt: "2026-03-17T19:12:30.000Z", + streaming: false, + }, + }, + ], + "plain-text", + ); + + expect(markup).toContain("Copy response"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f8cfffbbe8..b4f6a6d668 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -58,7 +58,10 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; -import { resolveAssistantMessageCopyText } from "../../lib/assistantMessageCopy"; +import { + hasAssistantResponseCopyText, + resolveAssistantMessageCopyText, +} from "../../lib/assistantMessageCopy"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -259,7 +262,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (row.kind === "work") return 112; if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan); if (row.kind === "working") return 40; - return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); + return estimateTimelineMessageHeight(row.message, { + timelineWidthPx, + assistantResponseCopyFormat, + }); }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, @@ -443,21 +449,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.role === "assistant" && (() => { const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); - let assistantCopyText: string | null = null; - const getAssistantCopyText = () => { - if (assistantCopyText !== null) { - return assistantCopyText; - } - assistantCopyText = resolveAssistantMessageCopyText( - row.message.text, - assistantResponseCopyFormat, - ); - return assistantCopyText; - }; const showAssistantCopyButton = !row.message.streaming && - row.message.text.trim().length > 0 && - getAssistantCopyText().trim().length > 0; + hasAssistantResponseCopyText(row.message.text, assistantResponseCopyFormat); return ( <> {row.showCompletionDivider && ( @@ -534,7 +528,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({
{showAssistantCopyButton ? ( - + + resolveAssistantMessageCopyText( + row.message.text, + assistantResponseCopyFormat, + ) + } + label="Copy response" + /> ) : null}

diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 06f29236bb..9fb9f41412 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -25,6 +25,19 @@ describe("estimateTimelineMessageHeight", () => { ).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({ diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 454003969e..69dda9fb37 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -1,4 +1,6 @@ +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; @@ -27,6 +29,7 @@ interface TimelineMessageHeightInput { interface TimelineHeightEstimateLayout { timelineWidthPx: number | null; + assistantResponseCopyFormat?: AssistantResponseCopyFormat; } function estimateWrappedLineCount(text: string, charsPerLine: number): number { @@ -76,7 +79,8 @@ export function estimateTimelineMessageHeight( const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); const assistantBaseHeightPx = - message.streaming !== true && message.text.trim().length > 0 + 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; diff --git a/apps/web/src/lib/assistantMessageCopy.test.ts b/apps/web/src/lib/assistantMessageCopy.test.ts index 0d3de5db6b..11d92cf840 100644 --- a/apps/web/src/lib/assistantMessageCopy.test.ts +++ b/apps/web/src/lib/assistantMessageCopy.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { markdownToPlainText, resolveAssistantMessageCopyText } from "./assistantMessageCopy"; +import { + hasAssistantResponseCopyText, + markdownToPlainText, + resolveAssistantMessageCopyText, +} from "./assistantMessageCopy"; describe("assistantMessageCopy", () => { it("returns the raw assistant markdown unchanged in markdown mode", () => { @@ -71,4 +75,19 @@ describe("assistantMessageCopy", () => { ["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); + }); }); diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts index 3ae810a945..2c6b96cf04 100644 --- a/apps/web/src/lib/assistantMessageCopy.ts +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -10,10 +10,13 @@ type MarkdownNode = { url?: string; ordered?: boolean; start?: number | null; + checked?: boolean | null; children?: MarkdownNode[]; }; const markdownProcessor = unified().use(remarkParse).use(remarkGfm); +const plainTextCache = new Map(); +const MAX_PLAIN_TEXT_CACHE_ENTRIES = 500; export function resolveAssistantMessageCopyText( messageText: string, @@ -23,7 +26,18 @@ export function resolveAssistantMessageCopyText( return messageText; } - return markdownToPlainText(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 { @@ -58,6 +72,7 @@ function renderBlock(node: MarkdownNode): string { case "table": return renderTable(node); case "html": + return normalizeCodeBlock(node.value ?? ""); case "thematicBreak": return ""; default: @@ -80,17 +95,19 @@ 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 marker.trimEnd(); + return contentPrefix.trimEnd(); } - const continuationPrefix = " ".repeat(marker.length); + 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 ? `${marker}${line}` : `${continuationPrefix}${line}`, + index === 0 ? `${contentPrefix}${line}` : `${continuationPrefix}${line}`, ); for (const block of remainingBlocks) { @@ -121,6 +138,7 @@ function renderInline(node: MarkdownNode): string { switch (node.type) { case "text": case "inlineCode": + case "html": return node.value ?? ""; case "break": return "\n"; @@ -166,3 +184,17 @@ function normalizePlainText(value: string): string { 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 (typeof cached === "string") { + return cached; + } + + const plainText = markdownToPlainText(markdown); + if (plainTextCache.size >= MAX_PLAIN_TEXT_CACHE_ENTRIES) { + plainTextCache.clear(); + } + plainTextCache.set(markdown, plainText); + return plainText; +} From 65c6a83df86ff785f0db6042d42f231b3e56bd0b Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 22:51:18 -0300 Subject: [PATCH 5/8] Re-measure timeline on copy format change --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b4f6a6d668..6acb41f824 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -274,7 +274,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ useEffect(() => { if (timelineWidthPx === null) return; rowVirtualizer.measure(); - }, [rowVirtualizer, timelineWidthPx]); + }, [assistantResponseCopyFormat, rowVirtualizer, timelineWidthPx]); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; From 48038957c09a26f608a26e674b64cbf89751b839 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 22:59:57 -0300 Subject: [PATCH 6/8] Use LRU cache for assistant plain text --- apps/web/src/lib/assistantMessageCopy.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts index 2c6b96cf04..6f134d9972 100644 --- a/apps/web/src/lib/assistantMessageCopy.ts +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -2,6 +2,7 @@ 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; @@ -15,8 +16,12 @@ type MarkdownNode = { }; const markdownProcessor = unified().use(remarkParse).use(remarkGfm); -const plainTextCache = new Map(); 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, @@ -187,14 +192,11 @@ function splitLines(value: string): string[] { function getCachedPlainText(markdown: string): string { const cached = plainTextCache.get(markdown); - if (typeof cached === "string") { + if (cached !== null) { return cached; } const plainText = markdownToPlainText(markdown); - if (plainTextCache.size >= MAX_PLAIN_TEXT_CACHE_ENTRIES) { - plainTextCache.clear(); - } - plainTextCache.set(markdown, plainText); + plainTextCache.set(markdown, plainText, plainText.length * 2); return plainText; } From 0897f669a5d7315f1866e78ca141cce3a03c07a5 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 23:13:31 -0300 Subject: [PATCH 7/8] Preserve leading indentation in plain text copy --- apps/web/src/lib/assistantMessageCopy.test.ts | 6 ++++++ apps/web/src/lib/assistantMessageCopy.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/assistantMessageCopy.test.ts b/apps/web/src/lib/assistantMessageCopy.test.ts index 11d92cf840..2513f34ef4 100644 --- a/apps/web/src/lib/assistantMessageCopy.test.ts +++ b/apps/web/src/lib/assistantMessageCopy.test.ts @@ -90,4 +90,10 @@ describe("assistantMessageCopy", () => { 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')"); + }); }); diff --git a/apps/web/src/lib/assistantMessageCopy.ts b/apps/web/src/lib/assistantMessageCopy.ts index 6f134d9972..0d4c697637 100644 --- a/apps/web/src/lib/assistantMessageCopy.ts +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -183,7 +183,7 @@ function normalizePlainText(value: string): string { return value .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") - .trim(); + .replace(/\n+$/g, ""); } function splitLines(value: string): string[] { From 059279bc09c64b5892cd7b6359ddbf8120337df2 Mon Sep 17 00:00:00 2001 From: Xavier Date: Fri, 27 Mar 2026 23:34:16 -0300 Subject: [PATCH 8/8] Collapse soft line breaks in plain text copy --- apps/web/src/lib/assistantMessageCopy.test.ts | 6 ++++++ apps/web/src/lib/assistantMessageCopy.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/apps/web/src/lib/assistantMessageCopy.test.ts b/apps/web/src/lib/assistantMessageCopy.test.ts index 2513f34ef4..b7f0bcfaf4 100644 --- a/apps/web/src/lib/assistantMessageCopy.test.ts +++ b/apps/web/src/lib/assistantMessageCopy.test.ts @@ -96,4 +96,10 @@ describe("assistantMessageCopy", () => { 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 index 0d4c697637..60927595e2 100644 --- a/apps/web/src/lib/assistantMessageCopy.ts +++ b/apps/web/src/lib/assistantMessageCopy.ts @@ -142,6 +142,7 @@ function renderInlineChildren(nodes: readonly MarkdownNode[]): string { function renderInline(node: MarkdownNode): string { switch (node.type) { case "text": + return (node.value ?? "").replace(/\r?\n+/g, " "); case "inlineCode": case "html": return node.value ?? "";