From 1968879fe5979ec44afe6e3beead0ea1c3e2947b Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Thu, 18 Jun 2026 05:12:15 +0300 Subject: [PATCH] feat(ai-chat): add Markdown export button for agent chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an "Export chat" button to the AI agent chat window header that downloads the active conversation as a Markdown file. The export is client-only: it serializes the already-loaded persisted message rows (no new network call, no server/DB change) and includes the request internals the chat already holds — tool calls with their input/output, per-message token usage, finish reason and error info. - New util apps/client/src/features/ai-chat/utils/export-chat.ts: buildChatMarkdown() + exportChatAsMarkdown(); reuses tool-parts helpers so tool labels match the on-screen labels; fence() escapes embedded code fences; slugify() yields a safe filename with a chatId fallback; downloads via file-saver's saveAs. - ai-chat-window.tsx: IconFileExport button in the header, shown only for a saved chat with loaded rows (canExport); drag is unaffected. - en-US: add "Export chat" and "You" i18n keys. --- .../public/locales/en-US/translation.json | 2 + .../ai-chat/components/ai-chat-window.tsx | 33 +++ .../src/features/ai-chat/utils/export-chat.ts | 194 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 apps/client/src/features/ai-chat/utils/export-chat.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 59a498e5..1496e315 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -947,6 +947,8 @@ "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}}", "Automatically provision users and groups from your identity provider via SCIM.": "Automatically provision users and groups from your identity provider via SCIM.", diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 1e4b0ed8..ef3aabe6 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -10,6 +10,7 @@ import { Group, Loader, Tooltip } from "@mantine/core"; import { IconArrowsDiagonal, IconChevronDown, + IconFileExport, IconGripVertical, IconMinus, IconPlus, @@ -33,6 +34,7 @@ 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 classes from "@/features/ai-chat/components/ai-chat-window.module.css"; // Default window geometry (from the GitmostAgent.jsx design). @@ -154,6 +156,26 @@ export default function AiChatWindow() { queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY }); }, [activeChatId, queryClient]); + // The active chat object (for its title) and an export gate: only enable the + // export button when an existing chat with loaded persisted rows is active. + const activeChat = useMemo( + () => chats?.items?.find((c) => c.id === activeChatId) ?? null, + [chats, activeChatId], + ); + 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(() => { + if (!activeChatId || !messageRows || messageRows.length === 0) return; + exportChatAsMarkdown({ + title: activeChat?.title ?? null, + chatId: activeChatId, + rows: messageRows, + t, + }); + }, [activeChatId, messageRows, activeChat, t]); + // When awaiting a new chat's id, adopt the most-recent chat (the list is // ordered newest-first) once it appears. useEffect(() => { @@ -308,6 +330,17 @@ export default function AiChatWindow() {
+ {canExport && ( + + )}