From 725824ec096ef35732433d0394fc662d88176e25 Mon Sep 17 00:00:00 2001 From: MacKinley Smith Date: Wed, 30 Sep 2026 11:28:34 -0600 Subject: [PATCH 1/6] feat(web): copy chat tables as tab-separated text Adds Copy as TSV to the table copy menu and a Table copy format setting that applies to the message copy button and highlight-copy. Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 1 + apps/web/src/components/ChatMarkdown.tsx | 15 ++- .../src/components/chat/MessageCopyButton.tsx | 5 +- .../src/components/chat/MessagesTimeline.tsx | 15 ++- .../components/settings/SettingsPanels.tsx | 53 +++++++++ .../src/components/settings/settingsSearch.ts | 6 + apps/web/src/markdown-clipboard.test.ts | 42 ++++++- apps/web/src/markdown-clipboard.ts | 105 +++++++++++++++--- packages/contracts/src/settings.ts | 9 ++ pnpm-lock.yaml | 3 + 10 files changed, 226 insertions(+), 28 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 92b30fba5c92c..435126c61d5fb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,6 +55,7 @@ "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" }, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4a1f616aed3b7..9f33d67ea8a9b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -32,6 +32,7 @@ import type { EnvironmentId, ScopedThreadRef, ServerProviderSkill, + TableCopyFormat, ThreadPullRequestKey, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; @@ -143,7 +144,7 @@ import { useTheme } from "../hooks/useTheme"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, - serializeTableElementToCsv, + serializeTableElementToDelimited, serializeTableElementToMarkdown, } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; @@ -344,7 +345,7 @@ const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; interface MarkdownActionFailureContext { readonly operation: string; readonly target?: string; - readonly format?: "markdown" | "csv"; + readonly format?: TableCopyFormat; readonly language?: string; readonly fenceTitle?: string; readonly copyTarget?: string; @@ -751,7 +752,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { setExpanded((value) => !value); } - const handleCopy = useCallback((format: "markdown" | "csv") => { + const handleCopy = useCallback((format: TableCopyFormat) => { const table = containerRef.current?.querySelector("table"); if (!table || typeof navigator === "undefined" || navigator.clipboard == null) { return; @@ -759,7 +760,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const text = format === "markdown" ? serializeTableElementToMarkdown(table) - : serializeTableElementToCsv(table); + : serializeTableElementToDelimited(table, format); void navigator.clipboard .writeText(text) .then(() => { @@ -838,6 +839,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { handleCopy("markdown")}>Copy as Markdown + handleCopy("tsv")}>Copy as TSV handleCopy("csv")}>Copy as CSV @@ -2450,11 +2452,12 @@ function useChatMarkdownState({ return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); // Re-emit highlighted content as markdown so copying out of the rendered - // view keeps links, emphasis, lists, and code fences intact. + // view keeps links, emphasis, lists, and code fences intact. Tables follow + // the table copy format setting. const handleCopy = useCallback((event: ReactClipboardEvent) => { const selection = window.getSelection(); if (!selection || selection.isCollapsed || !event.clipboardData) return; - const payload = chatMarkdownClipboardPayload(selection); + const payload = chatMarkdownClipboardPayload(selection, getClientSettings().tableCopyFormat); if (!payload) return; event.preventDefault(); event.clipboardData.setData("text/plain", payload.text); diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index 3a647857bae9e..13580951805d0 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -16,7 +16,8 @@ export const MessageCopyButton = memo(function MessageCopyButton({ variant = "outline", className, }: { - text: string; + /** A function defers building the copied text until the click. */ + text: string | (() => string); /** Additional clipboard types written beside `text/plain` when the platform allows it. */ extraFlavors?: Readonly>; size?: "xs" | "icon-xs"; @@ -38,7 +39,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({