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; + } +}