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:
@@ -253,6 +253,7 @@
|
||||
"Invite link": "Invite link",
|
||||
"Copy": "Copy",
|
||||
"Copy to space": "Copy to space",
|
||||
"Copy chat": "Copy chat",
|
||||
"Copied": "Copied",
|
||||
"Duplicate": "Duplicate",
|
||||
"Select a user": "Select a user",
|
||||
@@ -947,7 +948,6 @@
|
||||
"Try a different search term.": "Try a different search term.",
|
||||
"Try again": "Try again",
|
||||
"Untitled chat": "Untitled chat",
|
||||
"Export chat": "Export chat",
|
||||
"You": "You",
|
||||
"What can I help you with?": "What can I help you with?",
|
||||
"Are you sure you want to revoke this {{credential}}": "Are you sure you want to revoke this {{credential}}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user