Skip to content

Commit 02665ac

Browse files
committed
Add Markdown export and copy-as-markdown for chats
Turn the export button into a menu with Markdown export/copy alongside the existing JSON export, for sharing a conversation somewhere Markdown renders (docs, issues, notes) without hand-editing the JSON dump.
1 parent 60b87ce commit 02665ac

7 files changed

Lines changed: 126 additions & 9 deletions

File tree

‎app/src/data-transfer.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, expect } from "vitest";
2+
import { sessionToMarkdown } from "./data-transfer";
3+
import type { ChatSession } from "./sessions-store";
4+
5+
function makeSession(overrides: Partial<ChatSession> = {}): ChatSession {
6+
return {
7+
id: "1",
8+
title: "Test chat",
9+
model: "llama3.2",
10+
messages: [],
11+
createdAt: "2026-01-01T00:00:00.000Z",
12+
updatedAt: "2026-01-01T00:00:00.000Z",
13+
...overrides,
14+
};
15+
}
16+
17+
describe("sessionToMarkdown", () => {
18+
it("renders the title as a heading and each message with its speaker", () => {
19+
const md = sessionToMarkdown(
20+
makeSession({
21+
messages: [
22+
{ role: "user", content: "Hello" },
23+
{ role: "assistant", content: "Hi there" },
24+
],
25+
})
26+
);
27+
28+
expect(md).toContain("# Test chat");
29+
expect(md).toContain("**User:**\n\nHello");
30+
expect(md).toContain("**Assistant:**\n\nHi there");
31+
});
32+
33+
it("omits tool-role messages and empty tool-call assistant messages", () => {
34+
const md = sessionToMarkdown(
35+
makeSession({
36+
messages: [
37+
{ role: "user", content: "What's 2+2?" },
38+
{ role: "assistant", content: "", toolCalls: [{ id: "1", name: "calc", arguments: {} }] },
39+
{ role: "tool", content: "4", toolCallId: "1", toolName: "calc" },
40+
{ role: "assistant", content: "It's 4." },
41+
],
42+
})
43+
);
44+
45+
expect(md).not.toContain("calc");
46+
expect(md).not.toContain("**Tool:**");
47+
expect(md).toContain("It's 4.");
48+
});
49+
});

‎app/src/data-transfer.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,34 @@ export async function exportSession(win: BrowserWindow | null, id: string): Prom
2424
return { success: true };
2525
}
2626

27+
// Tool-call/result messages are omitted from the Markdown rendering — they're
28+
// agent-mode bookkeeping, not something a reader sharing/archiving a
29+
// conversation cares about seeing.
30+
export function sessionToMarkdown(session: ChatSession): string {
31+
const lines: string[] = [`# ${session.title}`, ""];
32+
for (const m of session.messages) {
33+
if (m.role === "tool" || (m.role === "assistant" && !m.content && m.toolCalls?.length)) continue;
34+
const speaker = m.role === "user" ? "User" : m.role === "assistant" ? "Assistant" : "System";
35+
lines.push(`**${speaker}:**`, "", m.content, "");
36+
}
37+
return lines.join("\n");
38+
}
39+
40+
export async function exportSessionMarkdown(win: BrowserWindow | null, id: string): Promise<{ success: boolean }> {
41+
const session = sessionsStore.getSession(id);
42+
if (!session) return { success: false };
43+
44+
const options = {
45+
defaultPath: `${sanitizeFilename(session.title)}.md`,
46+
filters: [{ name: "Markdown", extensions: ["md"] }],
47+
};
48+
const result = win ? await dialog.showSaveDialog(win, options) : await dialog.showSaveDialog(options);
49+
if (result.canceled || !result.filePath) return { success: false };
50+
51+
fs.writeFileSync(result.filePath, sessionToMarkdown(session));
52+
return { success: true };
53+
}
54+
2755
export async function exportAllSessions(win: BrowserWindow | null): Promise<{ success: boolean }> {
2856
const sessions = sessionsStore.listSessions();
2957
const date = new Date().toISOString().slice(0, 10);

‎app/src/main.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,13 @@ function registerIpcHandlers(): void {
502502
ipcMain.handle("data:exportSession", (_event: IpcMainInvokeEvent, id: string) =>
503503
dataTransfer.exportSession(mainWindow, requireString(id, "session id"))
504504
);
505+
ipcMain.handle("data:exportSessionMarkdown", (_event: IpcMainInvokeEvent, id: string) =>
506+
dataTransfer.exportSessionMarkdown(mainWindow, requireString(id, "session id"))
507+
);
508+
ipcMain.handle("data:getSessionMarkdown", (_event: IpcMainInvokeEvent, id: string) => {
509+
const session = sessionsStore.getSession(requireString(id, "session id"));
510+
return session ? dataTransfer.sessionToMarkdown(session) : null;
511+
});
505512
ipcMain.handle("data:exportAll", () => dataTransfer.exportAllSessions(mainWindow));
506513
ipcMain.handle("data:import", () => dataTransfer.importSessions(mainWindow));
507514
ipcMain.handle("data:getUserDataPath", () => dataTransfer.getUserDataPath());

‎app/src/preload.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ contextBridge.exposeInMainWorld("api", {
186186

187187
data: {
188188
exportSession: (id: string) => ipcRenderer.invoke("data:exportSession", id),
189+
exportSessionMarkdown: (id: string) => ipcRenderer.invoke("data:exportSessionMarkdown", id),
190+
getSessionMarkdown: (id: string): Promise<string | null> => ipcRenderer.invoke("data:getSessionMarkdown", id),
189191
exportAll: () => ipcRenderer.invoke("data:exportAll"),
190192
import: () => ipcRenderer.invoke("data:import"),
191193
getUserDataPath: () => ipcRenderer.invoke("data:getUserDataPath"),

‎frontend/src/lib/translations.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ export interface Dictionary {
219219
usingCustomPrompt: string;
220220
dataManagement: string;
221221
exportAllConversations: string;
222+
exportAsMarkdown: string;
223+
copyAsMarkdown: string;
224+
exportAsJson: string;
222225
exportAllDescription: string;
223226
export: string;
224227
importConversations: string;
@@ -481,6 +484,9 @@ export const en: Dictionary = {
481484
usingCustomPrompt: "Custom prompt for this chat",
482485
dataManagement: "Data management",
483486
exportAllConversations: "Export all conversations",
487+
exportAsMarkdown: "Export as Markdown",
488+
copyAsMarkdown: "Copy as Markdown",
489+
exportAsJson: "Export as JSON",
484490
exportAllDescription: "Save every chat to a single JSON file.",
485491
export: "Export",
486492
importConversations: "Import conversations",
@@ -751,6 +757,9 @@ export const tr: Dictionary = {
751757
usingCustomPrompt: "Bu sohbet için özel istem",
752758
dataManagement: "Veri yönetimi",
753759
exportAllConversations: "Tüm sohbetleri dışa aktar",
760+
exportAsMarkdown: "Markdown olarak dışa aktar",
761+
copyAsMarkdown: "Markdown olarak kopyala",
762+
exportAsJson: "JSON olarak dışa aktar",
754763
exportAllDescription: "Tüm sohbetleri tek bir JSON dosyasına kaydedin.",
755764
export: "Dışa aktar",
756765
importConversations: "Sohbetleri içe aktar",

‎frontend/src/pages/Chat.tsx‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,16 @@ export default function Chat() {
773773
if (sessionId) await window.api.data.exportSession(sessionId);
774774
}
775775

776+
async function handleExportChatMarkdown() {
777+
if (sessionId) await window.api.data.exportSessionMarkdown(sessionId);
778+
}
779+
780+
async function handleCopyChatMarkdown() {
781+
if (!sessionId) return;
782+
const markdown = await window.api.data.getSessionMarkdown(sessionId);
783+
if (markdown) await navigator.clipboard.writeText(markdown);
784+
}
785+
776786
async function runCompletion(
777787
history: ChatMessage[],
778788
baseMessages: ChatMessage[],
@@ -1352,15 +1362,25 @@ export default function Chat() {
13521362
</span>
13531363
)}
13541364

1355-
<Button
1356-
size="icon"
1357-
variant="outline"
1358-
className={sessionCost > 0 ? "" : "ml-auto"}
1359-
onClick={handleExportChat}
1360-
aria-label="Export chat"
1361-
>
1362-
<FileDown className="size-4" />
1363-
</Button>
1365+
<DropdownMenu>
1366+
<DropdownMenuTrigger
1367+
render={
1368+
<Button
1369+
size="icon"
1370+
variant="outline"
1371+
className={sessionCost > 0 ? "" : "ml-auto"}
1372+
aria-label="Export chat"
1373+
>
1374+
<FileDown className="size-4" />
1375+
</Button>
1376+
}
1377+
/>
1378+
<DropdownMenuContent align="end">
1379+
<DropdownMenuItem onClick={handleExportChatMarkdown}>{t.exportAsMarkdown}</DropdownMenuItem>
1380+
<DropdownMenuItem onClick={handleCopyChatMarkdown}>{t.copyAsMarkdown}</DropdownMenuItem>
1381+
<DropdownMenuItem onClick={handleExportChat}>{t.exportAsJson}</DropdownMenuItem>
1382+
</DropdownMenuContent>
1383+
</DropdownMenu>
13641384

13651385
<Popover>
13661386
<PopoverTrigger

‎frontend/src/types/electron.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,8 @@ export interface ElectronApi {
439439
};
440440
data: {
441441
exportSession: (id: string) => Promise<{ success: boolean }>;
442+
exportSessionMarkdown: (id: string) => Promise<{ success: boolean }>;
443+
getSessionMarkdown: (id: string) => Promise<string | null>;
442444
exportAll: () => Promise<{ success: boolean }>;
443445
import: () => Promise<{ imported: number }>;
444446
getUserDataPath: () => Promise<string>;

0 commit comments

Comments
 (0)