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