From 5dc865d0ba3e08147435e10a397a2f42e2f3f551 Mon Sep 17 00:00:00 2001 From: dylantiranadz Date: Sun, 19 Jul 2026 19:43:10 -0500 Subject: [PATCH 1/2] feat(web): add transcript export to the session menu Serialize the derived transcript rows to Markdown or JSON from the session context menu. The draft rows the screen shows are the export content, with a provenance header that records the host, model, freshness, and any completeness warnings. Markdown caps long tool output; JSON carries the rows verbatim. SessionMain hands the menu a row getter through a ref, so no second runtime subscription is needed. --- apps/web/src/components/SessionScreen.tsx | 72 +++++- .../src/features/transcript/SessionMain.tsx | 19 +- .../src/features/transcript/export.test.ts | 226 ++++++++++++++++++ apps/web/src/features/transcript/export.ts | 196 +++++++++++++++ 4 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/features/transcript/export.test.ts create mode 100644 apps/web/src/features/transcript/export.ts diff --git a/apps/web/src/components/SessionScreen.tsx b/apps/web/src/components/SessionScreen.tsx index 3cdf51d..3ab985d 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..bb416de --- /dev/null +++ b/apps/web/src/features/transcript/export.test.ts @@ -0,0 +1,226 @@ +// 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 carries rows verbatim; 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("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); + }); +}); + +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..7d06879 --- /dev/null +++ b/apps/web/src/features/transcript/export.ts @@ -0,0 +1,196 @@ +// 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; JSON carries the rows verbatim. +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 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\`\`\`json\n${boundedBlock(call.args)}\n\`\`\``); + } + for (const line of call.progress) { + parts.push(`> ${line}`); + } + if (call.result !== null) { + parts.push(`Result:\n\n\`\`\`json\n${boundedBlock(call.result)}\n\`\`\``); + } + 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 rows as structured JSON for tooling. Rows are plain serializable + * data; nothing is capped or reformatted. + */ +export function transcriptRowsToJson( + rows: readonly TranscriptRow[], + meta: ExportMeta, +): string { + const document: TranscriptExportDocument = { version: 1, meta, rows }; + 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}`; +} From b0503ab35e7e2000c622b5004efd8490deaf92bb Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:01:49 -0700 Subject: [PATCH 2/2] fix(web): harden transcript export serialization --- .../src/features/transcript/export.test.ts | 29 ++++++++++++++++++- apps/web/src/features/transcript/export.ts | 27 +++++++++++++---- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/apps/web/src/features/transcript/export.test.ts b/apps/web/src/features/transcript/export.test.ts index bb416de..6209572 100644 --- a/apps/web/src/features/transcript/export.test.ts +++ b/apps/web/src/features/transcript/export.test.ts @@ -1,7 +1,7 @@ // 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 carries rows verbatim; filenames are filesystem-safe. +// output while JSON preserves durable rows; filenames are filesystem-safe. import { describe, expect, it } from "vite-plus/test"; import { @@ -142,6 +142,22 @@ describe("transcriptRowsToMarkdown", () => { 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" }, @@ -201,6 +217,17 @@ describe("transcriptRowsToJson", () => { 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", () => { diff --git a/apps/web/src/features/transcript/export.ts b/apps/web/src/features/transcript/export.ts index 7d06879..f62dd2f 100644 --- a/apps/web/src/features/transcript/export.ts +++ b/apps/web/src/features/transcript/export.ts @@ -2,7 +2,7 @@ // 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; JSON carries the rows verbatim. +// 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"; @@ -45,6 +45,16 @@ function boundedBlock(value: unknown): string { 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"; @@ -54,13 +64,13 @@ function toolCallStatus(call: TranscriptToolCall): string { 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\`\`\`json\n${boundedBlock(call.args)}\n\`\`\``); + 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\`\`\`json\n${boundedBlock(call.result)}\n\`\`\``); + parts.push(`Result:\n\n${fencedJson(call.result)}`); } if (call.images.length > 0) { parts.push(`_${call.images.length} image(s) attached_`); @@ -161,14 +171,19 @@ export interface ExportContent { } /** - * The same rows as structured JSON for tooling. Rows are plain serializable - * data; nothing is capped or reformatted. + * 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 }; + const document: TranscriptExportDocument = { + version: 1, + meta, + rows: rows.filter((row) => row.kind !== "working"), + }; return `${JSON.stringify(document, null, 2)}\n`; }