Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
"remark-gfm": "catalog:",
"remark-parse": "catalog:",
"tailwind-merge": "^3.4.0",
"unbash": "^5.0.0",
"zustand": "^5.0.11"
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
type EnvironmentId,
type ScopedThreadRef,
type ServerProviderSkill,
type TableCopyFormat,
type ThreadPullRequestKey,
} from "@t3tools/contracts";
import { faviconUrlForOrigin } from "@t3tools/shared/favicon";
Expand Down Expand Up @@ -162,7 +163,7 @@ import { useTheme } from "../hooks/useTheme";
import { getClientSettings, useClientSettings } from "../hooks/useSettings";
import {
chatMarkdownClipboardPayload,
serializeTableElementToCsv,
serializeTableElementToDelimited,
serializeTableElementToMarkdown,
} from "../markdown-clipboard";
import {
Expand Down Expand Up @@ -350,7 +351,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;
Expand Down Expand Up @@ -573,15 +574,15 @@ 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;
}
const text =
format === "markdown"
? serializeTableElementToMarkdown(table)
: serializeTableElementToCsv(table);
: serializeTableElementToDelimited(table, format);
void navigator.clipboard
.writeText(text)
.then(() => {
Expand Down Expand Up @@ -660,6 +661,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
</Tooltip>
<MenuPopup align="end">
<MenuItem onClick={() => handleCopy("markdown")}>Copy as Markdown</MenuItem>
<MenuItem onClick={() => handleCopy("tsv")}>Copy as TSV</MenuItem>
<MenuItem onClick={() => handleCopy("csv")}>Copy as CSV</MenuItem>
</MenuPopup>
</Menu>
Expand Down Expand Up @@ -2404,7 +2406,7 @@ function useChatMarkdownState({
const handleCopy = useCallback((event: ReactClipboardEvent<HTMLDivElement>) => {
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);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/MessageCopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({
variant = "outline",
className,
}: {
text: string;
text: string | (() => string);
label?: string;
icon?: typeof Copy;
extraFlavors?: Readonly<Record<string, string>>;
Expand All @@ -43,7 +43,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({
<Button
aria-label={label}
disabled={isCopied}
onClick={() => copyToClipboard(text)}
onClick={() => copyToClipboard(typeof text === "function" ? text() : text)}
ref={ref}
type="button"
size={size}
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,14 @@ import {
encodeComposerContextClipboardHtml,
encodeComposerContextFragment,
} from "@t3tools/shared/composerContextClipboard";
import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";
import {
chatMarkdownClipboardPayload,
markdownWithTableCopyFormat,
} from "../../markdown-clipboard";
import { ContextChip, ContextChipLabel, type ContextChipKind } from "../ContextChip";
import { createContextPresentationRegistry } from "../contextPresentationRegistry";
import { useOpenPrLink } from "~/lib/openPullRequestLink";
import { useClientSettings } from "~/hooks/useSettings";
import { getClientSettings, useClientSettings } from "~/hooks/useSettings";
import type { ChatMarkdownContextReference } from "../ChatMarkdown";
import { useMediaQuery } from "~/hooks/useMediaQuery";
import { cn } from "~/lib/utils";
Expand Down Expand Up @@ -2940,7 +2943,13 @@ function AssistantCopyButton({
return null;
}

return <MessageCopyButton text={assistantCopyState.text ?? ""} variant="ghost" />;
const text = assistantCopyState.text ?? "";
return (
<MessageCopyButton
text={() => markdownWithTableCopyFormat(text, getClientSettings().tableCopyFormat)}
variant="ghost"
/>
);
}

function ProposedPlanTimelineRow({
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
DEFAULT_UNIFIED_SETTINGS,
type ChatWidth,
type TableCopyFormat,
type DiffLayout,
type EnvironmentIdentificationMode,
MAX_APPEARANCE_CONTRAST,
Expand Down Expand Up @@ -200,6 +201,12 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;

const TABLE_COPY_FORMAT_LABELS: Record<TableCopyFormat, string> = {
markdown: "Markdown",
tsv: "Tab-separated",
csv: "CSV",
};

const CHAT_WIDTH_LABELS: Record<ChatWidth, string> = {
comfortable: "Comfortable",
wide: "Wide",
Expand Down Expand Up @@ -551,6 +558,9 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
? ["Environment identification"]
: []),
...(settings.tableCopyFormat !== DEFAULT_UNIFIED_SETTINGS.tableCopyFormat
? ["Table copy format"]
: []),
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
Expand Down Expand Up @@ -719,6 +729,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.sidebarThreadPreviewCount,
settings.showSkillsInSlashMenu,
settings.timestampFormat,
settings.tableCopyFormat,
settings.notificationMode,
settings.inAppNotificationsEnabled,
settings.wordWrap,
Expand Down Expand Up @@ -789,6 +800,7 @@ export function useSettingsRestore(onRestored?: () => void) {
diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth,
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
tableCopyFormat: DEFAULT_UNIFIED_SETTINGS.tableCopyFormat,
notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
Expand Down Expand Up @@ -2505,6 +2517,47 @@ export function GeneralSettingsPanel() {
</Select>
}
/>
<SettingsRow
{...searchableSetting("table-copy-format")}
description="How tables are written when you copy a message or a selection. Tab-separated pastes as a table into Slack and spreadsheets."
resetAction={
settings.tableCopyFormat !== DEFAULT_UNIFIED_SETTINGS.tableCopyFormat ? (
<SettingResetButton
label="table copy format"
onClick={() =>
updateSettings({
tableCopyFormat: DEFAULT_UNIFIED_SETTINGS.tableCopyFormat,
})
}
/>
) : null
}
control={
<Select
value={settings.tableCopyFormat}
onValueChange={(value) => {
if (value === "markdown" || value === "tsv" || value === "csv") {
updateSettings({ tableCopyFormat: value });
}
}}
>
<SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Table copy format">
<SelectValue>{TABLE_COPY_FORMAT_LABELS[settings.tableCopyFormat]}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="markdown">
{TABLE_COPY_FORMAT_LABELS.markdown}
</SelectItem>
<SelectItem hideIndicator value="tsv">
{TABLE_COPY_FORMAT_LABELS.tsv}
</SelectItem>
<SelectItem hideIndicator value="csv">
{TABLE_COPY_FORMAT_LABELS.csv}
</SelectItem>
</SelectPopup>
</Select>
}
/>
<SettingsRow
serverScoped
settingKeys={["responseStreamingMode"]}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,12 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"],
},
{
id: "table-copy-format",
title: "Table copy format",
to: "/settings/general",
searchTerms: ["clipboard markdown tsv csv tab separated spreadsheet slack paste"],
},
{
id: "response-streaming",
title: "Response streaming",
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/markdown-clipboard.selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// @vitest-environment jsdom

import { afterEach, describe, expect, it } from "vite-plus/test";

import { chatMarkdownClipboardPayload } from "./markdown-clipboard";

function selectBetween(start: Node, end: Node): Selection {
const range = document.createRange();
range.setStart(start, 0);
range.setEnd(end, end.childNodes.length);
const selection = window.getSelection();
if (!selection) throw new Error("jsdom has no selection");
selection.removeAllRanges();
selection.addRange(range);
return selection;
}

describe("chatMarkdownClipboardPayload", () => {
afterEach(() => {
document.body.innerHTML = "";
});

it("copies a selection spanning table rows as rows, even though the range omits the table", () => {
document.body.innerHTML = `
<table>
<thead><tr><th>Question</th><th>Owner</th></tr></thead>
<tbody><tr><td>Loader URL?</td><td>Platform</td></tr></tbody>
</table>`;
const cells = document.querySelectorAll("th, td");
const selection = selectBetween(cells[0]!, cells[3]!);

expect(chatMarkdownClipboardPayload(selection, "tsv")?.text).toBe(
"Question\tOwner\nLoader URL?\tPlatform",
);
expect(chatMarkdownClipboardPayload(selection)?.text).toBe(
"| Question | Owner |\n| --- | --- |\n| Loader URL? | Platform |",
);
});

it("keeps empty edge cells, line breaks, and image alt text inside cells", () => {
document.body.innerHTML = `
<p>Status</p>
<table>
<thead><tr><th>Question</th><th>Owner</th></tr></thead>
<tbody>
<tr><td></td><td>Platform</td></tr>
<tr><td>first<br>second</td><td></td></tr>
<tr><td><img alt="Build badge" src="b.svg"></td><td>ok</td></tr>
</tbody>
</table>`;
const selection = selectBetween(document.body, document.body);

expect(chatMarkdownClipboardPayload(selection, "tsv")?.text).toBe(
"Status\n\nQuestion\tOwner\n\tPlatform\nfirst second\t\nBuild badge\tok",
);
});

it("keeps a selection inside one cell as plain text", () => {
document.body.innerHTML = `<table><tbody><tr><td>Platform</td></tr></tbody></table>`;
const cell = document.querySelector("td")!;

expect(chatMarkdownClipboardPayload(selectBetween(cell, cell), "tsv")?.text).toBe("Platform");
});
});
35 changes: 35 additions & 0 deletions apps/web/src/markdown-clipboard.table.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// @vitest-environment jsdom

import { describe, expect, it } from "vite-plus/test";

import { serializeTableElementToDelimited } from "./markdown-clipboard";

function renderTable(html: string): HTMLTableElement {
const container = document.createElement("div");
container.innerHTML = html;
return container.querySelector("table")!;
}

describe("serializeTableElementToDelimited", () => {
const table = renderTable(`
<table>
<thead><tr><th>Question</th><th>Owner, team</th></tr></thead>
<tbody>
<tr><td></td><td>Platform</td></tr>
<tr><td>first<br>second\tthird</td><td></td></tr>
<tr><td><img alt="Build badge" src="b.svg"></td><td>say "ok"</td></tr>
</tbody>
</table>`);

it("keeps every cell in its column as tab-separated rows", () => {
expect(serializeTableElementToDelimited(table, "tsv")).toBe(
'Question\tOwner, team\n\tPlatform\nfirst second third\t\nBuild badge\tsay "ok"',
);
});

it("quotes CSV cells that contain commas or quotes", () => {
expect(serializeTableElementToDelimited(table, "csv")).toBe(
'Question,"Owner, team"\n,Platform\nfirst second third,\nBuild badge,"say ""ok"""',
);
});
});
56 changes: 55 additions & 1 deletion apps/web/src/markdown-clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { serializeRenderedMarkdownFragment } from "./markdown-clipboard";
import {
markdownWithTableCopyFormat,
serializeRenderedMarkdownFragment,
} from "./markdown-clipboard";
import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
import {
collectAssistantCitations,
Expand Down Expand Up @@ -331,3 +334,54 @@ describe("serializeRenderedMarkdownFragment", () => {
);
});
});

describe("markdownWithTableCopyFormat", () => {
const message = [
"Here is the status.",
"",
"| Question | Answer |",
"| --- | --- |",
"| **Loader** URL? | See [docs](https://example.com) and `v2\\|v3` |",
"| Owner, team | Platform |",
"| ![Build badge](https://example.com/b.svg) | ok |",
"",
"```md",
"| a | b |",
"| - | - |",
"```",
].join("\n");

it("rewrites tables as plain-text tab-separated rows and leaves everything else alone", () => {
expect(markdownWithTableCopyFormat(message, "tsv")).toBe(
[
"Here is the status.",
"",
"Question\tAnswer",
"Loader URL?\tSee docs and v2|v3",
"Owner, team\tPlatform",
"Build badge\tok",
"",
"```md",
"| a | b |",
"| - | - |",
"```",
].join("\n"),
);
});

it("fits each row to the header's width, as the rendered table does", () => {
const ragged = ["| a | b |", "| - | - |", "| 1 | 2 | 3 |", "| x |"].join("\n");

expect(markdownWithTableCopyFormat(ragged, "tsv")).toBe("a\tb\n1\t2\nx\t");
});

it("drops the quote prefix from every row of a quoted table", () => {
const quoted = ["> Note", ">", "> | a | b |", "> | - | - |", "> | 1 | 2 |"].join("\n");

expect(markdownWithTableCopyFormat(quoted, "tsv")).toBe("> Note\n>\na\tb\n1\t2");
});

it("quotes CSV cells that contain commas", () => {
expect(markdownWithTableCopyFormat(message, "csv")).toContain('"Owner, team",Platform');
});
});
Loading
Loading