diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx
index 30ac745e3..2e021277e 100644
--- a/desktop/frontend/src/App.tsx
+++ b/desktop/frontend/src/App.tsx
@@ -15,6 +15,7 @@ import {
Check,
Trash2,
X,
+ Download,
} from "lucide-react";
import logo from "./assets/logo.svg";
import { useT } from "./lib/i18n";
@@ -34,6 +35,7 @@ import { UpdateBanner } from "./components/UpdateBanner";
import { WorkspacePanel } from "./components/WorkspacePanel";
import { Tooltip } from "./components/Tooltip";
import { OnboardingOverlay } from "./components/OnboardingOverlay";
+import { copyToClipboard, exportFilename, exportToMarkdown } from "./lib/exportSession";
import { parseTodos } from "./lib/tools";
import { sessionActivityTime } from "./lib/session";
import type { ComposerInsertRequest, MemoryView, Mode, SessionMeta } from "./lib/types";
@@ -669,6 +671,37 @@ export default function App() {
[saveDoc, fetchMemory],
);
+ // Export the current transcript as Markdown. The download path uses a Blob +
+ // anchor click, which works in Wails' WebView the same as a regular browser
+ // (the WebView surfaces an OS save dialog for the download). We name the
+ // file with a local-time timestamp so successive exports don't collide on
+ // the user's disk. Copy-to-clipboard is the same path minus the download.
+ const onExportMarkdown = useCallback(() => {
+ const md = exportToMarkdown(state.items, { model: state.meta?.label, cwd: state.meta?.cwd });
+ const name = exportFilename();
+ if (typeof document === "undefined") return;
+ const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = name;
+ a.style.display = "none";
+ document.body.appendChild(a);
+ a.click();
+ requestAnimationFrame(() => {
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ });
+ }, [state.items, state.meta]);
+
+ // copyAsMarkdown mirrors onExportMarkdown but writes to the clipboard.
+ // Useful when the user wants to paste into a chat (Slack, GitHub issue,
+ // blog draft) rather than save a file. Same source-of-truth rendering.
+ const onCopyMarkdown = useCallback(async () => {
+ const md = exportToMarkdown(state.items, { model: state.meta?.label, cwd: state.meta?.cwd });
+ await copyToClipboard(md);
+ }, [state.items, state.meta]);
+
const sidebarExpandBlocked = sidebarCollapsed && workspacePreviewModeActive;
const sidebarToggleTitle = sidebarExpandBlocked
? t("sidebar.expandBlocked")
@@ -928,6 +961,15 @@ export default function App() {
+
+
+
diff --git a/desktop/frontend/src/lib/exportSession.ts b/desktop/frontend/src/lib/exportSession.ts
new file mode 100644
index 000000000..6eac25fe1
--- /dev/null
+++ b/desktop/frontend/src/lib/exportSession.ts
@@ -0,0 +1,160 @@
+// exportSession renders a transcript as Markdown suitable for sharing
+// (GitHub issue, blog post, internal wiki). The shape matches what a
+// reader would expect: a title and metadata header, then alternating
+// "## User" and "## Assistant" sections, with tool calls rendered as
+// collapsible-ish blocks. Code blocks keep their language
+// fences so pasting into GitHub preserves syntax highlighting.
+//
+// The function is intentionally pure: it takes the items array and
+// optional metadata, returns a string. No DOM, no bridge, no side
+// effects. The caller (App / a new ExportMenu component) decides
+// where the result goes — copy to clipboard, save via a Wails binding,
+// or pipe into a hidden iframe for a print-stylesheet PDF export.
+
+import type { Item } from "./useController";
+
+export interface ExportMeta {
+ // model is the model label shown in the header. Optional.
+ model?: string;
+ // cwd is the workspace folder; falls back to "unknown".
+ cwd?: string;
+ // startedAt / endedAt are ISO strings; the header renders a human
+ // duration. Both optional.
+ startedAt?: string;
+ endedAt?: string;
+ // title overrides the H1; defaults to "Reasonix session".
+ title?: string;
+}
+
+export function exportToMarkdown(items: Item[], meta: ExportMeta = {}): string {
+ const out: string[] = [];
+ const title = meta.title ?? "Reasonix session";
+ out.push(`# ${title}`);
+ out.push("");
+ // Metadata block. Each line is a single bullet so a downstream tool
+ // (blog generator, internal wiki) can parse it without a custom
+ // frontmatter reader.
+ const metaLines: string[] = [];
+ if (meta.model) metaLines.push(`- model: ${meta.model}`);
+ if (meta.cwd) metaLines.push(`- workspace: \`${meta.cwd}\``);
+ if (meta.startedAt) metaLines.push(`- started: ${meta.startedAt}`);
+ if (meta.endedAt) metaLines.push(`- ended: ${meta.endedAt}`);
+ if (meta.startedAt && meta.endedAt) {
+ const ms = Date.parse(meta.endedAt) - Date.parse(meta.startedAt);
+ if (Number.isFinite(ms) && ms > 0) metaLines.push(`- duration: ${formatDuration(ms)}`);
+ }
+ if (metaLines.length > 0) out.push(...metaLines, "");
+
+ // Walk the items. Tool calls with a parentId are sub-agent calls; they
+ // get rendered under the parent so the markdown reads as a tree, not a
+ // flat list. (The Transcript component does the same.)
+ const subcalls = new Map[]>();
+ for (const it of items) {
+ if (it.kind === "tool" && it.parentId) {
+ const arr = subcalls.get(it.parentId) ?? [];
+ arr.push(it);
+ subcalls.set(it.parentId, arr);
+ }
+ }
+ for (const it of items) {
+ switch (it.kind) {
+ case "user":
+ out.push("## User", "", it.text, "");
+ break;
+ case "assistant":
+ out.push("## Assistant", "", it.text || "", "");
+ break;
+ case "tool":
+ if (it.parentId) break; // rendered under its parent
+ if (it.name === "todo_write") break; // live task list, not part of the export
+ if (it.name === "exit_plan_mode") break; // the plan was the approval card
+ out.push(...renderTool(it, subcalls.get(it.id)), "");
+ break;
+ case "phase":
+ out.push(`*${it.text}*`, "");
+ break;
+ case "notice":
+ // Notices are ephemeral; skip in the export.
+ break;
+ case "compaction":
+ if (!it.pending) {
+ out.push(
+ `> Context compacted — ${it.messages} messages, trigger ${it.trigger}.`,
+ "",
+ "summary",
+ "",
+ it.summary,
+ "",
+ "",
+ "",
+ );
+ }
+ break;
+ }
+ }
+ return out.join("\n");
+}
+
+function renderTool(
+ it: Extract,
+ subs: Extract[] | undefined,
+): string[] {
+ const out: string[] = [];
+ out.push(`### \`${it.name}\``);
+ // The args string is a JSON blob for most tools; render it in a JSON
+ // code fence so a reader can copy it back into a request. (We don't
+ // try to render a pretty form — different tools have wildly different
+ // arg shapes, and a JSON fence round-trips.)
+ if (it.args && it.args.trim() && it.args.trim() !== "{}") {
+ out.push("", "```json", it.args, "```");
+ }
+ if (it.output) {
+ const truncated = it.truncated ? "\n…(output truncated)" : "";
+ out.push("", "**output**", "", "```", it.output, "```" + truncated);
+ }
+ if (it.error) {
+ out.push("", "**error**", "", "```", it.error, "```");
+ }
+ if (subs && subs.length > 0) {
+ out.push("", "**sub-calls**");
+ for (const s of subs) {
+ out.push(...renderTool(s, undefined));
+ }
+ }
+ return out;
+}
+
+function formatDuration(ms: number): string {
+ const s = Math.round(ms / 1000);
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ const rs = s % 60;
+ if (m < 60) return rs === 0 ? `${m}m` : `${m}m ${rs}s`;
+ const h = Math.floor(m / 60);
+ const rm = m % 60;
+ return rm === 0 ? `${h}h` : `${h}h ${rm}m`;
+}
+
+// exportFilename is a tiny helper that derives a filesystem-friendly
+// timestamp slug for the default save name: "reasonix-2026-06-04-1530.md".
+// Local time (not UTC) matches the user's wall clock and is fine for
+// this purpose — files within a session are minutes apart at most.
+export function exportFilename(prefix = "reasonix"): string {
+ const d = new Date();
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return `${prefix}-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}.md`;
+}
+
+// copyToClipboard writes the markdown to the system clipboard. A
+// separate function (vs. inlined in the menu) so the same code path
+// works for the keyboard shortcut, the menu button, and a future
+// "Copy as Markdown" cell action in the history drawer.
+export async function copyToClipboard(text: string): Promise {
+ if (typeof navigator === "undefined" || !navigator.clipboard) return false;
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ return false;
+ }
+}