diff --git a/apps/web/src/components/SessionScreen.tsx b/apps/web/src/components/SessionScreen.tsx
index 09e7cdd..dca56c4 100644
--- a/apps/web/src/components/SessionScreen.tsx
+++ b/apps/web/src/components/SessionScreen.tsx
@@ -21,6 +21,7 @@ import {
Check,
ChevronDown,
Cpu,
+ FileDown,
FolderGit2,
Laptop,
Maximize2,
@@ -30,7 +31,7 @@ import {
Wifi,
X,
} from "lucide-react";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import type {
WorkspaceHost,
@@ -39,6 +40,13 @@ import type {
} from "../lib/workspace-data.ts";
import { PaneContent } from "../features/panes/PaneContent.tsx";
import { TerminalDrawer } from "../features/terminal/TerminalDrawer.tsx";
+import {
+ transcriptFileName,
+ transcriptRowsToJson,
+ transcriptRowsToMarkdown,
+ type ExportContent,
+ type ExportMeta,
+} from "../features/transcript/export.ts";
import { FreshnessBadge, SessionMain, SessionOwnershipBadge } from "../features/transcript/SessionMain.tsx";
import { RIGHT_PANE_DOCK_QUERY, useMediaQuery } from "../hooks/useMediaQuery.ts";
import { rendererPlatform, useWorkspace, workspaceStore } from "../state/store-instance.ts";
@@ -61,11 +69,13 @@ const FRESHNESS_LABEL = {
function SessionContextMenu({
host,
+ onExport,
onOpenHostHealth,
project,
session,
}: {
host: WorkspaceHost | undefined;
+ onExport: (format: "md" | "json") => void;
onOpenHostHealth: () => void;
project: WorkspaceProject;
session: WorkspaceSession;
@@ -133,6 +143,33 @@ function SessionContextMenu({
>
View host health
+
+ Export transcript
+
+
+
+
+
@@ -275,6 +312,37 @@ export function SessionScreen({
const paneDocks = useMediaQuery(RIGHT_PANE_DOCK_QUERY);
const shellData = useShellData();
const host = shellData.hosts.find((entry) => entry.id === project.hostId);
+ // Filled by SessionMain with the current transcript rows; the context
+ // menu serializes whatever is there at click time.
+ const exportRowsRef = useRef<(() => ExportContent) | null>(null);
+ const handleExport = (format: "md" | "json") => {
+ const content = exportRowsRef.current?.();
+ if (content === null || content === undefined) return;
+ const exportedAt = new Date();
+ const meta: ExportMeta = {
+ sessionTitle: session.title,
+ projectName: project.name,
+ hostName: host?.name ?? "Unknown host",
+ model: session.model,
+ freshness: session.freshness,
+ exportedAt: exportedAt.toISOString(),
+ historyTruncated: content.historyTruncated,
+ turnActive: content.turnActive,
+ };
+ const text =
+ format === "json"
+ ? transcriptRowsToJson(content.rows, meta)
+ : transcriptRowsToMarkdown(content.rows, meta);
+ const blob = new Blob([text], {
+ type: format === "json" ? "application/json" : "text/markdown",
+ });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = transcriptFileName(session.title, format, exportedAt);
+ anchor.click();
+ URL.revokeObjectURL(url);
+ };
const runtimeSnapshot = useDesktopRuntimeSnapshot();
const previewAddress =
runtimeSnapshot === null ? null : resolveLiveSession(runtimeSnapshot, session.id);
@@ -322,6 +390,7 @@ export function SessionScreen({
void;
+ /** Export hook: registered with the current rows so the header menu can serialize them. */
+ readonly exportRowsRef: RefObject<(() => ExportContent) | null>;
}
/** Stable session-scoped destination; all transcript state stays in the workspace store. */
@@ -347,7 +350,7 @@ export function SessionControlBanner({
);
}
-export function SessionMain({ onOpenHostHealth, session }: SessionMainProps) {
+export function SessionMain({ onOpenHostHealth, session, exportRowsRef }: SessionMainProps) {
const archived = session.archivedAt !== undefined;
const navigate = useNavigate();
const { snapshot, runtime } = useSessionRuntime(session.id, session.freshness);
@@ -403,6 +406,18 @@ export function SessionMain({ onOpenHostHealth, session }: SessionMainProps) {
[projection, snapshot.pendingPrompts, snapshot.sessionActive],
);
const rows = useStableTranscriptRows(rawRows);
+ // The export menu lives in the header; rows live here. Hand the menu a
+ // getter instead of subscribing to the runtime a second time.
+ useEffect(() => {
+ exportRowsRef.current = () => ({
+ rows: rawRows,
+ historyTruncated: projection.historyTruncated,
+ turnActive: projection.turnActive,
+ });
+ return () => {
+ exportRowsRef.current = null;
+ };
+ }, [exportRowsRef, rawRows, projection]);
const attention = useMemo(() => deriveAttention(projection), [projection]);
const [revisingPlanId, setRevisingPlanId] = useState(null);
diff --git a/apps/web/src/features/transcript/export.test.ts b/apps/web/src/features/transcript/export.test.ts
new file mode 100644
index 0000000..6209572
--- /dev/null
+++ b/apps/web/src/features/transcript/export.test.ts
@@ -0,0 +1,253 @@
+// Export contract: the header names the session and says how complete the
+// view was; every row kind serializes; unknown entries are preserved, never
+// dropped; the transient working row is omitted; Markdown caps long tool
+// output while JSON preserves durable rows; filenames are filesystem-safe.
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ EXPORT_TOOL_OUTPUT_MAX_CHARS,
+ transcriptFileName,
+ transcriptRowsToJson,
+ transcriptRowsToMarkdown,
+ type ExportMeta,
+} from "./export.ts";
+import type { TranscriptRow, TranscriptToolCall } from "./rows.ts";
+
+function meta(overrides: Partial = {}): ExportMeta {
+ return {
+ sessionTitle: "Pin protocol fixtures",
+ projectName: "t4-code",
+ hostName: "studio-mac",
+ model: "fable-5",
+ freshness: "live",
+ exportedAt: "2026-07-19T12:00:00.000Z",
+ historyTruncated: false,
+ turnActive: false,
+ ...overrides,
+ };
+}
+
+function messageRow(overrides: Partial> = {}): TranscriptRow {
+ return {
+ id: "m1",
+ kind: "message",
+ role: "assistant",
+ text: "The build passes.",
+ reasoning: "",
+ images: [],
+ imageIssue: null,
+ live: false,
+ startedAt: "2026-07-19T11:00:00.000Z",
+ ...overrides,
+ };
+}
+
+function toolCall(overrides: Partial = {}): TranscriptToolCall {
+ return {
+ callId: "c1",
+ tool: "bash",
+ title: "Run tests",
+ args: { command: "pnpm test" },
+ state: "ok",
+ startedAt: "2026-07-19T11:01:00.000Z",
+ progress: [],
+ result: { exitCode: 0 },
+ endedAt: "2026-07-19T11:01:30.000Z",
+ images: [],
+ imageIssue: null,
+ ...overrides,
+ };
+}
+
+describe("transcriptRowsToMarkdown", () => {
+ it("writes the provenance header", () => {
+ const out = transcriptRowsToMarkdown([], meta());
+ expect(out).toContain("# Pin protocol fixtures");
+ expect(out).toContain("Project: t4-code · Host: studio-mac · Model: fable-5");
+ expect(out).toContain("Exported: 2026-07-19T12:00:00.000Z · View: live");
+ expect(out).not.toContain("WARNING");
+ });
+
+ it("warns when history was truncated", () => {
+ const out = transcriptRowsToMarkdown([], meta({ historyTruncated: true }));
+ expect(out).toContain("WARNING: older history was no longer retained");
+ });
+
+ it("warns when the view is cached or offline", () => {
+ expect(transcriptRowsToMarkdown([], meta({ freshness: "cached" }))).toContain(
+ "WARNING: exported from a cached or offline view",
+ );
+ expect(transcriptRowsToMarkdown([], meta({ freshness: "offline" }))).toContain(
+ "WARNING: exported from a cached or offline view",
+ );
+ });
+
+ it("notes a running turn", () => {
+ expect(transcriptRowsToMarkdown([], meta({ turnActive: true }))).toContain(
+ "a turn was still running at export time",
+ );
+ });
+
+ it("serializes a message with reasoning and image count", () => {
+ const out = transcriptRowsToMarkdown(
+ [
+ messageRow({
+ role: "user",
+ text: "Fix the tests",
+ reasoning: "Let me think.",
+ images: [{ entryId: "e1", sha256: "ab", mimeType: "image/png" }],
+ }),
+ ],
+ meta(),
+ );
+ expect(out).toContain("## User");
+ expect(out).toContain("Fix the tests");
+ expect(out).toContain("> Reasoning: Let me think.");
+ expect(out).toContain("_1 image(s) attached_");
+ });
+
+ it("serializes tool calls with status, args, and result", () => {
+ const out = transcriptRowsToMarkdown(
+ [{ id: "g1", kind: "tool-group", calls: [toolCall()], running: false }],
+ meta(),
+ );
+ expect(out).toContain("### Run tests (`bash`) — ok");
+ expect(out).toContain('"command": "pnpm test"');
+ expect(out).toContain('"exitCode": 0');
+ });
+
+ it("marks running and errored calls", () => {
+ const out = transcriptRowsToMarkdown(
+ [
+ {
+ id: "g1",
+ kind: "tool-group",
+ calls: [toolCall({ state: "running" }), toolCall({ callId: "c2", state: "error" })],
+ running: true,
+ },
+ ],
+ meta(),
+ );
+ expect(out).toContain("— running");
+ expect(out).toContain("— error");
+ });
+
+ it("caps long tool output and says so", () => {
+ const big = "x".repeat(EXPORT_TOOL_OUTPUT_MAX_CHARS + 500);
+ const out = transcriptRowsToMarkdown(
+ [{ id: "g1", kind: "tool-group", calls: [toolCall({ result: { output: big } })], running: false }],
+ meta(),
+ );
+ expect(out).toContain("… truncated for export");
+ expect(out.length).toBeLessThan(big.length);
+ });
+
+ it("uses a longer Markdown fence when tool data contains backticks", () => {
+ const out = transcriptRowsToMarkdown(
+ [
+ {
+ id: "g1",
+ kind: "tool-group",
+ calls: [toolCall({ args: { command: "printf '```'" } })],
+ running: false,
+ },
+ ],
+ meta(),
+ );
+ expect(out).toContain("````json\n");
+ expect(out).toContain("\n````");
+ });
+
+ it("serializes every notice kind", () => {
+ const notices: Extract["notice"][] = [
+ { kind: "error", id: "n1", message: "boom", retryable: true, at: "t" },
+ { kind: "retry", id: "n2", attempt: 2, reason: "flaky", at: "t" },
+ { kind: "compaction", id: "n3", summary: "folded", droppedEntries: 9, at: "t" },
+ { kind: "history-truncated", id: "n4", message: "old entries gone" },
+ { kind: "gap", id: "n5", reason: "reconnect", missing: 3, at: "t" },
+ { kind: "protocol", id: "n6", message: "odd frame", at: "t" },
+ ];
+ const out = transcriptRowsToMarkdown(
+ notices.map((notice, index) => ({ id: `row-${index}`, kind: "notice" as const, notice })),
+ meta(),
+ );
+ expect(out).toContain("> Error: boom");
+ expect(out).toContain("> Retry attempt 2: flaky");
+ expect(out).toContain("> Context compacted: folded (9 entries dropped)");
+ expect(out).toContain("> History truncated: old entries gone");
+ expect(out).toContain("> Gap in transcript: reconnect (3 events missing)");
+ expect(out).toContain("> Protocol notice: odd frame");
+ });
+
+ it("preserves unknown entries instead of dropping them", () => {
+ const out = transcriptRowsToMarkdown(
+ [
+ {
+ id: "u1",
+ kind: "unknown-entry",
+ entryKind: "future-widget",
+ data: {},
+ timestamp: "2026-07-19T11:02:00.000Z",
+ },
+ ],
+ meta(),
+ );
+ expect(out).toContain("> Unrecognized entry `future-widget`");
+ });
+
+ it("omits the transient working row", () => {
+ const out = transcriptRowsToMarkdown(
+ [{ id: "w1", kind: "working", startedAt: null, activity: "working" }],
+ meta(),
+ );
+ expect(out).not.toContain("working");
+ });
+});
+
+describe("transcriptRowsToJson", () => {
+ it("carries version, meta, and rows verbatim", () => {
+ const big = "x".repeat(EXPORT_TOOL_OUTPUT_MAX_CHARS + 500);
+ const rows = [
+ messageRow(),
+ { id: "g1", kind: "tool-group", calls: [toolCall({ result: { output: big } })], running: false },
+ ] as const;
+ const parsed = JSON.parse(transcriptRowsToJson(rows, meta()));
+ expect(parsed.version).toBe(1);
+ expect(parsed.meta.sessionTitle).toBe("Pin protocol fixtures");
+ expect(parsed.rows).toHaveLength(2);
+ expect(parsed.rows[1].calls[0].result.output).toHaveLength(big.length);
+ });
+
+ it("omits the transient working row", () => {
+ const parsed = JSON.parse(
+ transcriptRowsToJson(
+ [{ id: "w1", kind: "working", startedAt: null, activity: "working" }],
+ meta({ turnActive: true }),
+ ),
+ );
+ expect(parsed.rows).toEqual([]);
+ expect(parsed.meta.turnActive).toBe(true);
+ });
+});
+
+describe("transcriptFileName", () => {
+ const at = new Date("2026-07-19T12:34:56.000Z");
+
+ it("slugifies the title and stamps the time", () => {
+ expect(transcriptFileName("Pin protocol fixtures for CI!", "md", at)).toBe(
+ "t4-transcript-pin-protocol-fixtures-for-ci-20260719-123456.md",
+ );
+ });
+
+ it("falls back when the title has no slug characters", () => {
+ expect(transcriptFileName("!!!", "json", at)).toBe(
+ "t4-transcript-session-20260719-123456.json",
+ );
+ });
+
+ it("caps the slug length", () => {
+ const name = transcriptFileName("a".repeat(200), "md", at);
+ expect(name.length).toBeLessThan(90);
+ expect(name.endsWith(".md")).toBe(true);
+ });
+});
diff --git a/apps/web/src/features/transcript/export.ts b/apps/web/src/features/transcript/export.ts
new file mode 100644
index 0000000..f62dd2f
--- /dev/null
+++ b/apps/web/src/features/transcript/export.ts
@@ -0,0 +1,211 @@
+// Transcript export: serialize the already-derived transcript rows into a
+// downloadable artifact. What you see is what you export — the same rows
+// the screen shows, in the same order, with a provenance header that says
+// exactly how complete the view was at export time. Markdown caps long tool
+// output for readability; both formats omit the transient working indicator.
+import type { CollaborationMessage } from "./collaboration-messages.ts";
+import type { TranscriptNotice } from "./projection.ts";
+import type { TranscriptRow, TranscriptToolCall } from "./rows.ts";
+
+/** Longest tool argument/result block the Markdown format keeps inline. */
+export const EXPORT_TOOL_OUTPUT_MAX_CHARS = 4_000;
+
+export interface ExportMeta {
+ readonly sessionTitle: string;
+ readonly projectName: string;
+ readonly hostName: string;
+ readonly model: string;
+ readonly freshness: "live" | "cached" | "offline";
+ /** ISO timestamp of the export itself. */
+ readonly exportedAt: string;
+ readonly historyTruncated: boolean;
+ readonly turnActive: boolean;
+}
+
+function metaLines(meta: ExportMeta): string[] {
+ const lines = [
+ `- Project: ${meta.projectName} · Host: ${meta.hostName} · Model: ${meta.model}`,
+ `- Exported: ${meta.exportedAt} · View: ${meta.freshness}`,
+ ];
+ if (meta.historyTruncated) {
+ lines.push("- WARNING: older history was no longer retained; this export is partial.");
+ }
+ if (meta.freshness !== "live") {
+ lines.push("- WARNING: exported from a cached or offline view, not the live host.");
+ }
+ if (meta.turnActive) {
+ lines.push("- Note: a turn was still running at export time.");
+ }
+ return lines;
+}
+
+function boundedBlock(value: unknown): string {
+ const text = JSON.stringify(value, null, 2) ?? "null";
+ if (text.length <= EXPORT_TOOL_OUTPUT_MAX_CHARS) return text;
+ return `${text.slice(0, EXPORT_TOOL_OUTPUT_MAX_CHARS)}\n… truncated for export`;
+}
+
+function fencedJson(value: unknown): string {
+ const content = boundedBlock(value);
+ let longestBacktickRun = 0;
+ for (const match of content.matchAll(/`+/g)) {
+ longestBacktickRun = Math.max(longestBacktickRun, match[0].length);
+ }
+ const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
+ return `${fence}json\n${content}\n${fence}`;
+}
+
+function toolCallStatus(call: TranscriptToolCall): string {
+ if (call.state === "ok") return "ok";
+ if (call.state === "error") return "error";
+ return "running";
+}
+
+function toolCallToMarkdown(call: TranscriptToolCall): string {
+ const parts = [`### ${call.title} (\`${call.tool}\`) — ${toolCallStatus(call)}`];
+ if (Object.keys(call.args).length > 0) {
+ parts.push(`Arguments:\n\n${fencedJson(call.args)}`);
+ }
+ for (const line of call.progress) {
+ parts.push(`> ${line}`);
+ }
+ if (call.result !== null) {
+ parts.push(`Result:\n\n${fencedJson(call.result)}`);
+ }
+ if (call.images.length > 0) {
+ parts.push(`_${call.images.length} image(s) attached_`);
+ }
+ return parts.join("\n\n");
+}
+
+function collaborationToMarkdown(message: CollaborationMessage): string {
+ switch (message.variant) {
+ case "irc": {
+ const from = message.from ?? "unknown";
+ return [`### Peer message — ${message.status}`, `From: ${from}`, message.body].join("\n\n");
+ }
+ case "task-result": {
+ const parts = [`### Subagent result — ${message.status}`, message.body];
+ for (const job of message.jobs) {
+ const duration =
+ job.durationMs === null ? "duration unknown" : `${Math.round(job.durationMs / 1000)}s`;
+ parts.push(`> Job ${job.label} (${job.type}), ${duration}`);
+ }
+ return parts.join("\n\n");
+ }
+ case "collaborator":
+ return ["### Collaborator prompt", message.body].join("\n\n");
+ }
+}
+
+function noticeToMarkdown(notice: TranscriptNotice): string {
+ switch (notice.kind) {
+ case "error":
+ return `> Error: ${notice.message}`;
+ case "retry":
+ return `> Retry attempt ${notice.attempt}: ${notice.reason}`;
+ case "compaction":
+ return `> Context compacted: ${notice.summary} (${notice.droppedEntries} entries dropped)`;
+ case "history-truncated":
+ return `> History truncated: ${notice.message}`;
+ case "gap":
+ return `> Gap in transcript: ${notice.reason} (${notice.missing} events missing)`;
+ case "protocol":
+ return `> Protocol notice: ${notice.message}`;
+ }
+}
+
+function rowToMarkdown(row: TranscriptRow): string | null {
+ switch (row.kind) {
+ case "message": {
+ const parts = [row.role === "user" ? "## User" : "## Assistant"];
+ if (row.reasoning !== "") {
+ parts.push(`> Reasoning: ${row.reasoning.replace(/\n/g, "\n> ")}`);
+ }
+ if (row.text !== "") parts.push(row.text);
+ if (row.images.length > 0) parts.push(`_${row.images.length} image(s) attached_`);
+ if (row.imageIssue !== null) parts.push(`> Image issue: ${row.imageIssue}`);
+ return parts.join("\n\n");
+ }
+ case "tool-group":
+ return row.calls.map(toolCallToMarkdown).join("\n\n");
+ case "collaboration":
+ return collaborationToMarkdown(row.message);
+ case "notice":
+ return noticeToMarkdown(row.notice);
+ case "unknown-entry":
+ return `> Unrecognized entry \`${row.entryKind}\` (${row.timestamp})`;
+ case "working":
+ return null;
+ }
+}
+
+/**
+ * Serialize the rows the screen shows into a Markdown document with a
+ * provenance header. Rows in, string out; nothing is invented or dropped
+ * except the transient "working" indicator, which the header covers.
+ */
+export function transcriptRowsToMarkdown(
+ rows: readonly TranscriptRow[],
+ meta: ExportMeta,
+): string {
+ const parts = [`# ${meta.sessionTitle}`, ...metaLines(meta)];
+ for (const row of rows) {
+ const block = rowToMarkdown(row);
+ if (block !== null) parts.push(block);
+ }
+ return `${parts.join("\n\n")}\n`;
+}
+
+export interface TranscriptExportDocument {
+ readonly version: 1;
+ readonly meta: ExportMeta;
+ readonly rows: readonly TranscriptRow[];
+}
+
+/** What the export menu needs from the session surface at click time. */
+export interface ExportContent {
+ readonly rows: readonly TranscriptRow[];
+ readonly historyTruncated: boolean;
+ readonly turnActive: boolean;
+}
+
+/**
+ * The same durable rows as structured JSON for tooling. Rows are plain
+ * serializable data; nothing is capped or reformatted. The transient working
+ * indicator is represented by meta.turnActive instead of a synthetic row.
+ */
+export function transcriptRowsToJson(
+ rows: readonly TranscriptRow[],
+ meta: ExportMeta,
+): string {
+ const document: TranscriptExportDocument = {
+ version: 1,
+ meta,
+ rows: rows.filter((row) => row.kind !== "working"),
+ };
+ return `${JSON.stringify(document, null, 2)}\n`;
+}
+
+const SLUG_MAX_CHARS = 48;
+
+/** Filesystem-safe export name: slugged session title plus export time. */
+export function transcriptFileName(
+ sessionTitle: string,
+ extension: "md" | "json",
+ exportedAt: Date,
+): string {
+ const slug =
+ sessionTitle
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, SLUG_MAX_CHARS)
+ .replace(/-+$/g, "") || "session";
+ const stamp = exportedAt
+ .toISOString()
+ .replace(/[-:]/g, "")
+ .replace("T", "-")
+ .slice(0, 15);
+ return `t4-transcript-${slug}-${stamp}.${extension}`;
+}