feat(ai-chat): copy agent chat as Markdown to clipboard

Add a header button to the AI agent chat window that copies the active
conversation to the clipboard as Markdown, including the request
internals already persisted client-side — tool calls with their
input/output, per-message token usage, and finish/error info. No new
network call and no server/DB change: it serializes the already-loaded
persisted message rows.

- New util chat-markdown.ts (renamed from export-chat.ts): pure
  buildChatMarkdown() serializer reusing the tool-parts helpers so tool
  labels match the on-screen labels; fence() escapes embedded code
  fences.
- ai-chat-window.tsx: Copy button (shown only for a saved chat with
  loaded rows) using the project useClipboard hook; toggles a check
  icon on success and shows the standard "Copied" notification. Drag is
  unaffected (startDrag ignores button clicks).
- en-US: add "Copy chat" key, drop the obsolete "Export chat".
This commit is contained in:
vvzvlad
2026-06-18 17:53:18 +03:00
parent 8f5be58f9b
commit 06648d91bb
3 changed files with 26 additions and 48 deletions

View File

@@ -9,8 +9,9 @@ import {
import { Group, Loader, Tooltip } from "@mantine/core";
import {
IconArrowsDiagonal,
IconCheck,
IconChevronDown,
IconFileExport,
IconCopy,
IconGripVertical,
IconMinus,
IconPlus,
@@ -34,7 +35,9 @@ import {
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
import { exportChatAsMarkdown } from "@/features/ai-chat/utils/export-chat.ts";
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications";
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
// Default window geometry (from the GitmostAgent.jsx design).
@@ -90,6 +93,7 @@ function clampGeom(g: { left: number; top: number; width: number; height: number
*/
export default function AiChatWindow() {
const { t } = useTranslation();
const clipboard = useClipboard({ timeout: 500 });
const queryClient = useQueryClient();
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
@@ -165,16 +169,19 @@ export default function AiChatWindow() {
const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
// Build a Markdown export from the already-loaded persisted rows (no network
// call) and trigger a browser download. The download dialog is the feedback.
const handleExport = useCallback(() => {
// call) and copy it to the clipboard. The "Copied" notification is the
// feedback.
const handleCopy = useCallback(() => {
if (!activeChatId || !messageRows || messageRows.length === 0) return;
exportChatAsMarkdown({
const markdown = buildChatMarkdown({
title: activeChat?.title ?? null,
chatId: activeChatId,
rows: messageRows,
t,
});
}, [activeChatId, messageRows, activeChat, t]);
clipboard.copy(markdown);
notifications.show({ message: t("Copied") });
}, [activeChatId, messageRows, activeChat, clipboard, t]);
// When awaiting a new chat's id, adopt the most-recent chat (the list is
// ordered newest-first) once it appears.
@@ -334,11 +341,11 @@ export default function AiChatWindow() {
<button
type="button"
className={classes.headerBtn}
title={t("Export chat")}
aria-label={t("Export chat")}
onClick={handleExport}
title={t("Copy chat")}
aria-label={t("Copy chat")}
onClick={handleCopy}
>
<IconFileExport size={14} />
{clipboard.copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
</button>
)}
<button

View File

@@ -1,17 +1,16 @@
/**
* Client-only Markdown exporter for an AI agent chat. Serializes the already
* Client-only Markdown builder for an AI agent chat. Serializes the already
* persisted message rows (loaded via `useAiChatMessagesQuery`) into a single
* Markdown document and triggers a browser download. NO network call is made
* and NO server/DB code is touched this reuses the rich "request internals"
* (tool calls with input/output, per-message token usage, finish/error info)
* that the chat already holds client-side.
* Markdown string suitable for copying to the clipboard. NO network call is
* made and NO server/DB code is touched this reuses the rich "request
* internals" (tool calls with input/output, per-message token usage,
* finish/error info) that the chat already holds client-side.
*
* Only role labels and tool action labels are localized via the passed-in `t`
* translator; the structural document words (Input/Output/Error/Tokens/...) are
* plain English constants because the export is a technical artifact.
* plain English constants because the output is a technical artifact.
*/
import { saveAs } from "file-saver";
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import {
ToolUiPart,
@@ -23,7 +22,7 @@ import {
// Minimal translator signature compatible with react-i18next's `t`.
type Translate = (key: string, values?: Record<string, unknown>) => string;
interface ExportChatArgs {
interface BuildChatMarkdownArgs {
title: string | null;
chatId: string;
rows: IAiChatMessageRow[];
@@ -62,21 +61,6 @@ function fence(code: string, lang = ""): string {
return `${delim}${lang}\n${code}\n${delim}`;
}
/**
* Build a filesystem-safe slug from a chat title: lowercase, collapse any run
* of non-alphanumeric characters to a single "-", trim stray dashes, cap the
* length. Falls back to `chatId` when the title yields an empty slug.
*/
function slugify(title: string | null, chatId: string): string {
const slug = (title ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50)
.replace(/-+$/g, "");
return slug.length > 0 ? slug : chatId;
}
/** Per-row token count, mirroring the header sum in ai-chat-window.tsx. */
function rowTokens(usage: {
inputTokens?: number;
@@ -92,7 +76,7 @@ function rowTokens(usage: {
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
* export timestamp), so it is straightforward to unit-test.
*/
export function buildChatMarkdown(args: ExportChatArgs): string {
export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
const { title, chatId, rows, t } = args;
const blocks: string[] = [];
@@ -179,16 +163,3 @@ export function buildChatMarkdown(args: ExportChatArgs): string {
// Blank line between blocks so the Markdown renders cleanly.
return blocks.join("\n\n");
}
/**
* Build the Markdown, wrap it in a Blob, and trigger a browser download. The
* file name is `gitmost-chat-<slug>-<YYYYMMDD>.md`.
*/
export function exportChatAsMarkdown(args: ExportChatArgs): void {
const markdown = buildChatMarkdown(args);
const slug = slugify(args.title, args.chatId);
const date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const filename = `gitmost-chat-${slug}-${date}.md`;
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
saveAs(blob, filename);
}