From 4ca776dc838598c6d2a94031ef290b9b3af39ebe Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Wed, 30 Sep 2026 15:52:05 +0100 Subject: [PATCH 1/2] feat(web): export thread transcripts to Markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds "Export transcript…" to the thread action menu (sidebar rows, tab rows, chat header), the legacy sidebar, and the command palette. The dialog loads the full thread, previews it as Markdown in Concise or Full detail with an optional front matter header, and either saves a .md file on the client device or copies it to the clipboard. Desktop saves through a new native save-dialog IPC and can reveal the saved file; browsers use the save picker where available and fall back to a download. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/src/app/DesktopClerk.test.ts | 2 + apps/desktop/src/electron/ElectronDialog.ts | 46 +++ apps/desktop/src/electron/ElectronShell.ts | 2 + apps/desktop/src/ipc/DesktopIpcHandlers.ts | 4 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/window.test.ts | 59 ++++ apps/desktop/src/ipc/methods/window.ts | 40 +++ apps/desktop/src/preload.ts | 3 + .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 2 + apps/web/src/components/CommandPalette.tsx | 16 + apps/web/src/components/LegacySidebar.tsx | 6 + apps/web/src/components/Sidebar.tsx | 4 + .../src/components/TranscriptExportDialog.tsx | 285 ++++++++++++++++++ .../components/threadActionMenu.logic.test.ts | 15 +- .../src/components/threadActionMenu.logic.ts | 2 + apps/web/src/contextMenuFallback.ts | 5 + apps/web/src/hooks/useThreadActionMenu.ts | 4 + apps/web/src/lib/saveTextFile.ts | 68 +++++ apps/web/src/lib/threadTranscript.test.ts | 205 +++++++++++++ apps/web/src/lib/threadTranscript.ts | 152 ++++++++++ apps/web/src/routes/__root.tsx | 2 + docs/fork-differences.md | 16 + docs/user/thread-sidebar.md | 9 + .../src/state/threadSnapshotHttp.ts | 18 ++ packages/contracts/src/ipc.ts | 19 ++ 26 files changed, 984 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/TranscriptExportDialog.tsx create mode 100644 apps/web/src/lib/saveTextFile.ts create mode 100644 apps/web/src/lib/threadTranscript.test.ts create mode 100644 apps/web/src/lib/threadTranscript.ts diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index ba3a2cba04e1f..5f1c2b6b5561a 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -43,6 +43,7 @@ const makeDesktopClerkLayer = ( openExternal: () => Effect.succeed(true), openSystemSettings: () => Effect.succeed(false), copyText: () => Effect.void, + showItemInFolder: () => Effect.void, }, ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ @@ -308,6 +309,7 @@ for (const entry of ["startup", "open-url"] as const) { }), openSystemSettings: () => Effect.succeed(false), copyText: () => Effect.void, + showItemInFolder: () => Effect.void, }); const listeners = new Map void>(); const electronApp = { diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 18280333754c4..168e811270a11 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -36,6 +36,19 @@ export class ElectronDialogPickFilesError extends Schema.TaggedError()( + "ElectronDialogSaveFileError", + { + ownerWindowId: Schema.NullOr(Schema.Number), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`; + return `Failed to open the Electron save dialog for ${owner}.`; + } +} + export class ElectronDialogShowMessageBoxError extends Schema.TaggedError()( "ElectronDialogShowMessageBoxError", { @@ -69,6 +82,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedError; + readonly defaultPath: string; + readonly filters: readonly Electron.FileFilter[]; +} + export class ElectronDialog extends Context.Service< ElectronDialog, { @@ -95,6 +115,9 @@ export class ElectronDialog extends Context.Service< readonly pickFiles: ( input: ElectronDialogPickFilesInput, ) => Effect.Effect; + readonly saveFile: ( + input: ElectronDialogSaveFileInput, + ) => Effect.Effect, ElectronDialogSaveFileError>; readonly showMessageBox: ( options: Electron.MessageBoxOptions, ) => Effect.Effect; @@ -164,6 +187,29 @@ export const make = ElectronDialog.of({ }); return result.canceled ? [] : result.filePaths; }), + saveFile: Effect.fn("desktop.electron.dialog.saveFile")(function* (input) { + const saveDialogOptions: Electron.SaveDialogOptions = { + defaultPath: input.defaultPath, + filters: [...input.filters], + properties: ["createDirectory", "showOverwriteConfirmation"], + }; + const result = yield* Effect.tryPromise({ + try: () => + Option.match(input.owner, { + onNone: () => Electron.dialog.showSaveDialog(saveDialogOptions), + onSome: (owner) => Electron.dialog.showSaveDialog(owner, saveDialogOptions), + }), + catch: (cause) => + new ElectronDialogSaveFileError({ + ownerWindowId: Option.match(input.owner, { + onNone: () => null, + onSome: (owner) => owner.id, + }), + cause, + }), + }); + return result.canceled ? Option.none() : Option.fromNullishOr(result.filePath || undefined); + }), showMessageBox: (options) => Effect.tryPromise({ try: () => Electron.dialog.showMessageBox(options), diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 4e1c6514f2fdc..5d697fd62d1dc 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -61,6 +61,7 @@ export class ElectronShell extends Context.Service< /** Opens a known System Settings pane by identifier, not by URL. */ readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect; readonly copyText: (text: string) => Effect.Effect; + readonly showItemInFolder: (path: string) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronShell") {} @@ -86,6 +87,7 @@ export const make = ElectronShell.of({ ), copyText: (text) => Effect.promise(() => Electron.clipboard.writeText(text).catch(() => undefined)), + showItemInFolder: (path) => Effect.sync(() => Electron.shell.showItemInFolder(path)), }); export const layer = Layer.succeed(ElectronShell, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 4b43cd0eee966..a9d65bef87cb9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -51,8 +51,10 @@ import { pickFolder, pickProjectFavicon, pickThemeFiles, + saveTextFile, setTheme, showContextMenu, + showSavedFileInFolder, } from "./methods/window.ts"; import { acknowledgeSnapShot, @@ -129,6 +131,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pickFolder); yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); + yield* ipc.handle(saveTextFile); + yield* ipc.handle(showSavedFileInFolder); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 151cd633d04ed..9fc372a6509b7 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -2,6 +2,8 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; +export const SAVE_TEXT_FILE_CHANNEL = "desktop:save-text-file"; +export const SHOW_SAVED_FILE_IN_FOLDER_CHANNEL = "desktop:show-saved-file-in-folder"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 0b43fa9e6ef9a..afcd12d5bbe2b 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -22,6 +22,7 @@ vi.mock("electron", () => ({ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; +import * as ElectronShell from "../../electron/ElectronShell.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import type { DesktopSettings } from "../../settings/DesktopAppSettings.ts"; @@ -31,6 +32,8 @@ import { pasteAsText, pickProjectFavicon, probeRemoteEditors, + saveTextFile, + showSavedFileInFolder, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -266,6 +269,62 @@ describe("pickProjectFavicon", () => { ); }); +describe("saveTextFile", () => { + const saveLayer = ( + saveFile: () => Effect.Effect>, + showItemInFolder: (path: string) => Effect.Effect, + ) => + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ saveFile }), + Layer.mock(ElectronShell.ElectronShell)({ showItemInFolder }), + Layer.mock(ElectronWindow.ElectronWindow)({ focusedMainOrFirst: Effect.succeedNone }), + NodeServices.layer, + ); + + it.effect("writes the file the user picked and can reveal only that file", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-save-text-" }); + const target = path.join(directory, "chosen.md"); + const revealed: string[] = []; + const layer = saveLayer( + () => Effect.succeedSome(target), + (filePath) => Effect.sync(() => void revealed.push(filePath)), + ); + + const saved = yield* saveTextFile + .handler({ defaultFileName: "thread.md", contents: "# Thread\n" }) + .pipe(Effect.provide(layer)); + yield* showSavedFileInFolder + .handler(path.join(directory, "other.md")) + .pipe(Effect.provide(layer)); + yield* showSavedFileInFolder.handler(target).pipe(Effect.provide(layer)); + + assert.strictEqual(saved, target); + assert.strictEqual(yield* fs.readFileString(target), "# Thread\n"); + assert.deepEqual(revealed, [target]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("writes nothing when the dialog is cancelled", () => + Effect.gen(function* () { + const saved = yield* saveTextFile + .handler({ defaultFileName: "thread.md", contents: "# Thread\n" }) + .pipe( + Effect.provide( + saveLayer( + () => Effect.succeedNone, + () => Effect.void, + ), + ), + ); + + assert.strictEqual(saved, null); + }), + ); +}); + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( "finds remote editors installed without PATH launchers", () => diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 75f34ab07ab1e..dc516bc50bd5c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -8,6 +8,7 @@ import { PickedThemeFileSchema, PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, + SaveTextFileInputSchema, REMOTE_CAPABLE_EDITOR_IDS, SystemSettingsPaneSchema, type DesktopEnvironmentBootstrap, @@ -420,6 +421,45 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ }), }); +// Paths this process wrote through `saveTextFile`, so the renderer can only +// reveal files the user just saved rather than probe arbitrary paths. +const savedTextFilePaths = new Set(); + +export const saveTextFile = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SAVE_TEXT_FILE_CHANNEL, + payload: SaveTextFileInputSchema, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.saveTextFile")(function* (input) { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const extension = path.extname(input.defaultFileName).slice(1); + const selectedPath = yield* dialog.saveFile({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: path.join(NodeOS.homedir(), "Downloads", path.basename(input.defaultFileName)), + filters: extension === "md" ? [{ name: "Markdown", extensions: ["md"] }] : [], + }); + if (Option.isNone(selectedPath)) { + return null; + } + yield* fileSystem.writeFileString(selectedPath.value, input.contents); + savedTextFilePaths.add(selectedPath.value); + return selectedPath.value; + }), +}); + +export const showSavedFileInFolder = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SHOW_SAVED_FILE_IN_FOLDER_CHANNEL, + payload: Schema.String, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.showSavedFileInFolder")(function* (filePath) { + if (!savedTextFilePaths.has(filePath)) return; + const shell = yield* ElectronShell.ElectronShell; + yield* shell.showItemInFolder(filePath); + }), +}); + export const checkSystemPermission = DesktopIpc.makeIpcMethod({ channel: IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, payload: SystemSettingsPaneSchema, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e84a5821e9ff9..697812caab84e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -176,6 +176,9 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickProjectFavicon: (initialPath) => ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), + saveTextFile: (input) => ipcRenderer.invoke(IpcChannels.SAVE_TEXT_FILE_CHANNEL, input), + showSavedFileInFolder: (path) => + ipcRenderer.invoke(IpcChannels.SHOW_SAVED_FILE_IN_FOLDER_CHANNEL, path), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index d24ffdcba48f0..4abe91e8c1698 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -52,6 +52,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { pickFolder: () => Effect.succeedNone, pickFiles: () => Effect.succeed([]), + saveFile: () => Effect.succeedNone, showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, } satisfies ElectronDialog.ElectronDialog["Service"]); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5df400b90c44d..38e8e4661100a 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -308,6 +308,7 @@ function makeTestLayer(input: { Effect.sync(() => { input.copiedTexts?.push(text); }), + showItemInFolder: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, electronWindowLayer, @@ -410,6 +411,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n openExternal: () => Effect.succeed(true), openSystemSettings: () => Effect.succeed(true), copyText: () => Effect.void, + showItemInFolder: () => Effect.void, } satisfies ElectronShell.ElectronShell["Service"]), electronThemeLayer, Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b3ec774b898bb..c3a24fa012c4c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -47,6 +47,7 @@ import { ArrowLeftIcon, ChartNoAxesColumnIcon, CornerLeftUpIcon, + DownloadIcon, FileSearchIcon, FolderIcon, FolderPlusIcon, @@ -173,6 +174,7 @@ import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; +import { openTranscriptExportDialog } from "./TranscriptExportDialog"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; @@ -1828,6 +1830,20 @@ function OpenCommandPaletteDialog(props: { }); } + if (activeThread !== null) { + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + actionItems.push({ + kind: "action", + value: "action:export-transcript", + searchTerms: ["export", "transcript", "markdown", "save", "copy", "conversation"], + title: "Export transcript", + icon: , + run: async () => { + openTranscriptExportDialog(threadRef); + }, + }); + } + if ( activeThread !== null && threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported" diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 5c45605d31f87..7afef91b026a8 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -218,6 +218,7 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { openTranscriptExportDialog } from "./TranscriptExportDialog"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -2276,6 +2277,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, + { id: "export-transcript", label: "Export transcript…" }, { id: "project-settings", label: "Project settings" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ], @@ -2348,6 +2350,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; } + if (clicked === "export-transcript") { + openTranscriptExportDialog(threadRef); + return; + } if (clicked !== "delete") return; if (appSettingsConfirmThreadDelete) { const confirmed = await api.dialogs.confirm( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7b22d0032cd37..aae22881e4641 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -159,6 +159,7 @@ import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { openTranscriptExportDialog } from "./TranscriptExportDialog"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, @@ -4701,6 +4702,9 @@ export default function Sidebar() { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "export-transcript": + openTranscriptExportDialog(threadRef); + return; case "archive": { if (confirmThreadArchive) { const confirmed = await settlePromise(() => diff --git a/apps/web/src/components/TranscriptExportDialog.tsx b/apps/web/src/components/TranscriptExportDialog.tsx new file mode 100644 index 0000000000000..ef06933019d24 --- /dev/null +++ b/apps/web/src/components/TranscriptExportDialog.tsx @@ -0,0 +1,285 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { loadFullThreadSnapshot } from "@t3tools/client-runtime/state/threads"; +import type { OrchestrationThread, ScopedThreadRef } from "@t3tools/contracts"; +import { CopyIcon, DownloadIcon } from "lucide-react"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useMemo, useState } from "react"; + +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { runtime } from "~/lib/runtime"; +import { saveMarkdownFile, showSavedFileInFolder } from "~/lib/saveTextFile"; +import { + buildThreadTranscript, + transcriptFileName, + type TranscriptDetail, +} from "~/lib/threadTranscript"; +import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { useProject } from "~/state/entities"; +import { readPreparedConnection } from "~/state/session"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Spinner } from "./ui/spinner"; +import { Switch } from "./ui/switch"; +import { toastManager } from "./ui/toast"; +import { Toggle, ToggleGroup } from "./ui/toggle-group"; + +/** Long threads export in full; the preview only needs to show the shape. */ +const PREVIEW_MAX_CHARS = 20_000; + +const transcriptExportThreadAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("transcript-export:thread"), +); + +/** Opens the export dialog for a thread, from any menu, palette, or tab. */ +export function openTranscriptExportDialog(threadRef: ScopedThreadRef): void { + appAtomRegistry.set(transcriptExportThreadAtom, threadRef); +} + +function closeTranscriptExportDialog() { + appAtomRegistry.set(transcriptExportThreadAtom, null); +} + +// The last choices, so repeat exports skip the setup. +let lastOptions: { detail: TranscriptDetail; includeHeader: boolean } = { + detail: "concise", + includeHeader: true, +}; + +export function TranscriptExportDialogHost() { + const threadRef = useAtomValue(transcriptExportThreadAtom); + if (threadRef === null) return null; + return ( + + ); +} + +type LoadState = + | { readonly status: "loading" } + | { readonly status: "ready"; readonly thread: OrchestrationThread } + | { readonly status: "failed"; readonly message: string }; + +function useFullThread(threadRef: ScopedThreadRef): LoadState { + // The host keys this dialog by thread, so the ref is fixed for its lifetime. + const [state, setState] = useState(() => + readPreparedConnection(threadRef.environmentId) === null + ? { status: "failed", message: "This thread's environment is not connected." } + : { status: "loading" }, + ); + useEffect(() => { + const prepared = readPreparedConnection(threadRef.environmentId); + if (prepared === null) return; + let active = true; + void runtime.runPromise(loadFullThreadSnapshot(prepared, threadRef.threadId)).then( + (snapshot) => { + if (active) setState({ status: "ready", thread: snapshot.thread }); + }, + (error: unknown) => { + if (!active) return; + setState({ + status: "failed", + message: error instanceof Error ? error.message : "Could not load this thread.", + }); + }, + ); + return () => { + active = false; + }; + }, [threadRef]); + return state; +} + +function revealLabel(): string { + if (isMacPlatform(navigator.platform)) return "Reveal in Finder"; + if (isWindowsPlatform(navigator.platform)) return "Reveal in File Explorer"; + return "Reveal in Files"; +} + +function formatTokenEstimate(characters: number): string { + // ~4 characters per token is close enough to size a paste. + const tokens = Math.round(characters / 4); + return tokens < 1000 ? `~${tokens} tokens` : `~${(tokens / 1000).toFixed(1)}k tokens`; +} + +function TranscriptExportDialog({ threadRef }: { threadRef: ScopedThreadRef }) { + const load = useFullThread(threadRef); + const thread = load.status === "ready" ? load.thread : null; + const project = useProject( + thread ? scopeProjectRef(threadRef.environmentId, thread.projectId) : null, + ); + const [detail, setDetail] = useState(lastOptions.detail); + const [includeHeader, setIncludeHeader] = useState(lastOptions.includeHeader); + const [saving, setSaving] = useState(false); + useEffect(() => { + lastOptions = { detail, includeHeader }; + }, [detail, includeHeader]); + + const transcript = useMemo( + () => + thread + ? buildThreadTranscript({ + thread, + projectTitle: project?.title ?? null, + detail, + includeHeader, + exportedAt: new Date(), + }) + : null, + [thread, project?.title, detail, includeHeader], + ); + const fileName = thread ? transcriptFileName(thread.title) : null; + + const { copyToClipboard } = useCopyToClipboard({ + target: "transcript", + onCopy: () => { + closeTranscriptExportDialog(); + toastManager.add({ type: "success", title: "Transcript copied" }); + }, + onError: (error) => { + toastManager.add({ + type: "error", + title: "Could not copy transcript", + description: error.message, + }); + }, + }); + + const save = async () => { + if (!transcript || !fileName || saving) return; + setSaving(true); + try { + const saved = await saveMarkdownFile(fileName, transcript.markdown); + if (saved === null) return; + closeTranscriptExportDialog(); + toastManager.add( + saved.kind === "path" + ? { + type: "success", + title: "Transcript saved", + description: saved.path, + actionProps: { + children: revealLabel(), + onClick: () => showSavedFileInFolder(saved.path), + }, + } + : { type: "success", title: "Transcript saved", description: saved.fileName }, + ); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not save transcript", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } finally { + setSaving(false); + } + }; + + const stats = transcript + ? [ + `${transcript.messageCount} ${transcript.messageCount === 1 ? "message" : "messages"}`, + ...(detail === "full" + ? [ + `${transcript.toolCallCount} ${transcript.toolCallCount === 1 ? "tool call" : "tool calls"}`, + ] + : []), + formatTokenEstimate(transcript.markdown.length), + ].join(" · ") + : null; + const preview = + transcript && transcript.markdown.length > PREVIEW_MAX_CHARS + ? `${transcript.markdown.slice(0, PREVIEW_MAX_CHARS)}…` + : transcript?.markdown; + + return ( + { + if (!open) closeTranscriptExportDialog(); + }} + > + +
{ + event.preventDefault(); + void save(); + }} + > + + Export transcript + + + {[thread?.title, project?.title].filter(Boolean).join(" · ") || "Loading thread…"} + + + + +
+
+ { + const value = next[0]; + if (value === "concise" || value === "full") setDetail(value); + }} + > + Concise + Full + + +
+ {load.status === "ready" ? ( +
+                  {preview}
+                
+ ) : ( +
+ {load.status === "loading" ? : load.message} +
+ )} +
+ {fileName} + {stats} +
+
+
+ + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index b5f9de05c8de9..d17e2e1bf6073 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -48,18 +48,27 @@ describe("buildThreadActionMenuItems", () => { titleRegeneration: false, }, }), - ).toEqual(["rename", "mark-unread", "copy", "project-settings", "archive", "delete"]); + ).toEqual([ + "rename", + "mark-unread", + "copy", + "export-transcript", + "project-settings", + "archive", + "delete", + ]); }); it("groups project settings with utility actions before archive", () => { const items = buildThreadActionMenuItems(baseState); const copyIndex = items.findIndex((item) => item.id === "copy"); - expect(items[copyIndex + 1]).toMatchObject({ + expect(items[copyIndex + 1]?.id).toBe("export-transcript"); + expect(items[copyIndex + 2]).toMatchObject({ id: "project-settings", label: "Project settings", icon: "settings", }); - expect(items[copyIndex + 2]?.id).toBe("archive"); + expect(items[copyIndex + 3]?.id).toBe("archive"); }); it("offers project filtering only for surfaces with a scoped thread list", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index fe86edd441a8b..8d91003dc35da 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -29,6 +29,7 @@ export type ThreadActionMenuId = | "copy-path" | "copy-branch" | "copy-thread-id" + | "export-transcript" | "archive" | "delete"; @@ -184,6 +185,7 @@ export function buildThreadActionMenuItems( { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, ], }, + { id: "export-transcript", label: "Export transcript…", icon: "download" }, { id: "project-settings", label: "Project settings", icon: "settings" }, // Archive removes the thread from the sidebar while keeping its // conversation under Settings > Archived threads — distinct from Settle diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 6b4d3e7f154ab..039acc4413e57 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -10,6 +10,11 @@ const ICON_PATHS: Record diff --git a/apps/web/src/lib/saveTextFile.ts b/apps/web/src/lib/saveTextFile.ts new file mode 100644 index 0000000000000..f0cf0d6124bc4 --- /dev/null +++ b/apps/web/src/lib/saveTextFile.ts @@ -0,0 +1,68 @@ +/** Where a saved file ended up: a real path on desktop, or just its name in a browser. */ +export type SavedTextFile = + | { readonly kind: "path"; readonly path: string } + | { readonly kind: "browser"; readonly fileName: string }; + +interface SaveFilePickerWindow { + showSaveFilePicker?: (options: { + suggestedName: string; + types: ReadonlyArray<{ description: string; accept: Record }>; + }) => Promise<{ + name: string; + createWritable: () => Promise<{ + write: (data: Blob) => Promise; + close: () => Promise; + }>; + }>; +} + +/** + * Saves text on the device running the client, never the server: the native + * save dialog on desktop, the browser's save picker where it exists (Chromium), + * and a plain download elsewhere. Resolves null when the user cancels. + */ +export async function saveMarkdownFile( + fileName: string, + contents: string, +): Promise { + const bridge = window.desktopBridge; + if (bridge?.saveTextFile) { + const path = await bridge.saveTextFile({ defaultFileName: fileName, contents }); + return path === null ? null : { kind: "path", path }; + } + + const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); + const picker = (window as SaveFilePickerWindow).showSaveFilePicker; + if (picker) { + try { + const handle = await picker({ + suggestedName: fileName, + types: [{ description: "Markdown", accept: { "text/markdown": [".md"] } }], + }); + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + return { kind: "browser", fileName: handle.name }; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return null; + throw error; + } + } + + const url = URL.createObjectURL(blob); + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + } finally { + // Revoke after the click has handed the blob to the download. + window.setTimeout(() => URL.revokeObjectURL(url), 0); + } + return { kind: "browser", fileName }; +} + +/** Reveals a file saved through the desktop dialog; a no-op anywhere else. */ +export function showSavedFileInFolder(path: string): void { + void window.desktopBridge?.showSavedFileInFolder?.(path); +} diff --git a/apps/web/src/lib/threadTranscript.test.ts b/apps/web/src/lib/threadTranscript.test.ts new file mode 100644 index 0000000000000..2d4f3c97f2336 --- /dev/null +++ b/apps/web/src/lib/threadTranscript.test.ts @@ -0,0 +1,205 @@ +import { + ComposerContextId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationMessage, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildThreadTranscript, transcriptFileName } from "./threadTranscript"; + +const EXPORTED_AT = new Date("2026-09-30T15:18:00.000Z"); + +function message( + id: string, + role: OrchestrationMessage["role"], + text: string, + createdAt: string, + overrides: Partial = {}, +): OrchestrationMessage { + return { + id: MessageId.make(id), + role, + text, + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +function makeThread(overrides: Partial = {}): OrchestrationThread { + return { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "PR deployments: production endpoints", + modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "pr-deploy-prod", + worktreePath: null, + latestTurn: null, + createdAt: "2026-09-30T14:00:00.000Z", + updatedAt: "2026-09-30T15:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + pullRequests: [], + deletedAt: null, + messages: [ + message("m1", "user", "Can we deploy PRs to prod?", "2026-09-30T14:00:01.000Z"), + message("m2", "reasoning", "Thinking about workflows", "2026-09-30T14:00:02.000Z"), + message("m3", "assistant", "Yes, behind a flag.", "2026-09-30T14:00:05.000Z"), + message("m4", "user", "Go ahead.", "2026-09-30T14:01:00.000Z"), + message("m5", "assistant", "Done.", "2026-09-30T14:02:00.000Z"), + ], + proposedPlans: [], + activities: [ + { + id: EventId.make("a1"), + tone: "tool", + kind: "tool.completed", + summary: "Ran command", + payload: { itemType: "command_execution", data: { item: { command: ["make", "lint"] } } }, + turnId: null, + sequence: 1, + createdAt: "2026-09-30T14:01:30.000Z", + }, + ], + checkpoints: [], + session: null, + ...overrides, + }; +} + +describe("buildThreadTranscript", () => { + it("keeps only the conversation in concise mode", () => { + const transcript = buildThreadTranscript({ + thread: makeThread(), + projectTitle: "fd-manager", + detail: "concise", + includeHeader: false, + exportedAt: EXPORTED_AT, + }); + + expect(transcript.markdown).toBe( + [ + "# PR deployments: production endpoints", + "## User", + "Can we deploy PRs to prod?", + "## Assistant", + "Yes, behind a flag.", + "## User", + "Go ahead.", + "## Assistant", + "Done.", + ].join("\n\n") + "\n", + ); + expect(transcript.messageCount).toBe(4); + expect(transcript.toolCallCount).toBe(0); + }); + + it("interleaves tool calls and plans under the assistant in full mode", () => { + const transcript = buildThreadTranscript({ + thread: makeThread({ + proposedPlans: [ + { + id: "plan-1", + turnId: null, + planMarkdown: "1. Add a target input", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-09-30T14:00:04.000Z", + updatedAt: "2026-09-30T14:00:04.000Z", + }, + ], + }), + projectTitle: null, + detail: "full", + includeHeader: false, + exportedAt: EXPORTED_AT, + }); + + expect(transcript.markdown).toContain( + ["## Assistant", "### Plan\n\n1. Add a target input", "Yes, behind a flag."].join("\n\n"), + ); + expect(transcript.markdown).toContain( + ["Go ahead.", "## Assistant", "- **Ran command** `make lint`", "Done."].join("\n\n"), + ); + expect(transcript.markdown).not.toContain("Thinking about workflows"); + expect(transcript.toolCallCount).toBe(1); + }); + + it("writes a quoted front matter header", () => { + const { markdown } = buildThreadTranscript({ + thread: makeThread(), + projectTitle: "fd-manager", + detail: "concise", + includeHeader: true, + exportedAt: EXPORTED_AT, + }); + + expect( + markdown.startsWith( + [ + "---", + 'thread: "PR deployments: production endpoints"', + 'project: "fd-manager"', + 'branch: "pr-deploy-prod"', + 'provider: "claudeAgent"', + 'model: "claude-opus"', + "created: 2026-09-30T14:00:00.000Z", + "exported: 2026-09-30T15:18:00.000Z", + "---", + ].join("\n"), + ), + ).toBe(true); + }); + + it("replaces context chips with their labels and names unmentioned attachments", () => { + const chip = formatComposerContextReference({ + kind: "file", + contextId: ComposerContextId.make("ctx_1"), + label: "deploy.yml", + }); + const { markdown } = buildThreadTranscript({ + thread: makeThread({ + messages: [ + message("m1", "user", `Look at ${chip}`, "2026-09-30T14:00:01.000Z", { + attachments: [ + { + type: "image", + id: "att-1", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ], + }), + ], + }), + projectTitle: null, + detail: "concise", + includeHeader: false, + exportedAt: EXPORTED_AT, + }); + + expect(markdown).toContain("Look at deploy.yml\n\n[image: screenshot.png]"); + expect(markdown).not.toContain("t3-context:"); + }); +}); + +describe("transcriptFileName", () => { + it("slugs the title into a markdown file name", () => { + expect(transcriptFileName("PR deployments: production endpoints!")).toBe( + "pr-deployments-production-endpoints.md", + ); + expect(transcriptFileName("🚀")).toBe("transcript.md"); + }); +}); diff --git a/apps/web/src/lib/threadTranscript.ts b/apps/web/src/lib/threadTranscript.ts new file mode 100644 index 0000000000000..d0f5edb0b858d --- /dev/null +++ b/apps/web/src/lib/threadTranscript.ts @@ -0,0 +1,152 @@ +import type { OrchestrationThread } from "@t3tools/contracts"; +import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; + +import { deriveWorkLogEntries, type WorkLogEntry } from "../session-logic"; +import { resolveUserMessageContext } from "./composerContextRecords"; + +/** Concise keeps the conversation; full adds the work log and proposed plans. */ +export type TranscriptDetail = "concise" | "full"; + +export interface ThreadTranscript { + readonly markdown: string; + readonly messageCount: number; + readonly toolCallCount: number; +} + +type TranscriptItem = + | { readonly kind: "user" | "assistant"; readonly createdAt: string; readonly text: string } + | { readonly kind: "work"; readonly createdAt: string; readonly entry: WorkLogEntry } + | { readonly kind: "plan"; readonly createdAt: string; readonly markdown: string }; + +function userMessageText(message: OrchestrationThread["messages"][number]): string { + // Inline context chips are links to records only T3 can resolve; keep their labels. + const text = replaceComposerContextReferences( + resolveUserMessageContext(message).text, + (reference) => (reference.image ? `[image: ${reference.label}]` : reference.label), + ).trim(); + const unmentioned = (message.attachments ?? []) + .filter((attachment) => !text.includes(attachment.name)) + .map((attachment) => `[${attachment.type}: ${attachment.name}]`); + return [text, ...unmentioned].filter((part) => part.length > 0).join("\n\n"); +} + +/** Inline code that survives backticks inside the value. */ +function inlineCode(value: string): string { + const singleLine = value.replace(/\s+/g, " ").trim(); + const fence = singleLine.includes("`") ? "``" : "`"; + return fence === "``" ? `\`\` ${singleLine} \`\`` : `\`${singleLine}\``; +} + +function formatWorkEntry(entry: WorkLogEntry): string { + let line = `- **${entry.label}**`; + if (entry.command) { + line += ` ${inlineCode(entry.command)}`; + } else if (entry.detail && !entry.detail.includes("\n") && entry.detail.length <= 200) { + line += ` — ${entry.detail}`; + } + if (entry.changedFiles && entry.changedFiles.length > 0) { + line += `: ${entry.changedFiles.map(inlineCode).join(", ")}`; + } + return line; +} + +function yamlString(value: string): string { + return JSON.stringify(value); +} + +function formatHeader(input: { + readonly thread: OrchestrationThread; + readonly projectTitle: string | null; + readonly exportedAt: Date; +}): string { + const { thread } = input; + const lines = [ + `thread: ${yamlString(thread.title)}`, + ...(input.projectTitle ? [`project: ${yamlString(input.projectTitle)}`] : []), + ...(thread.branch ? [`branch: ${yamlString(thread.branch)}`] : []), + `provider: ${yamlString(thread.modelSelection.instanceId)}`, + `model: ${yamlString(thread.modelSelection.model)}`, + `created: ${thread.createdAt}`, + `exported: ${input.exportedAt.toISOString()}`, + ]; + return ["---", ...lines, "---"].join("\n"); +} + +/** + * Renders a thread as Markdown for pasting into another app. Reasoning and + * system messages are always left out; `full` interleaves the work log (tool + * calls, commands, file edits) and proposed plans in time order. + */ +export function buildThreadTranscript(input: { + readonly thread: OrchestrationThread; + readonly projectTitle: string | null; + readonly detail: TranscriptDetail; + readonly includeHeader: boolean; + readonly exportedAt: Date; +}): ThreadTranscript { + const { thread, detail } = input; + const items: TranscriptItem[] = []; + let messageCount = 0; + for (const message of thread.messages) { + if (message.role !== "user" && message.role !== "assistant") continue; + const text = message.role === "user" ? userMessageText(message) : message.text.trim(); + if (text.length === 0) continue; + messageCount += 1; + items.push({ kind: message.role, createdAt: message.createdAt, text }); + } + const workEntries = detail === "full" ? deriveWorkLogEntries(thread.activities) : []; + for (const entry of workEntries) { + items.push({ kind: "work", createdAt: entry.createdAt, entry }); + } + if (detail === "full") { + for (const plan of thread.proposedPlans) { + items.push({ kind: "plan", createdAt: plan.createdAt, markdown: plan.planMarkdown }); + } + } + // Stable sort keeps messages ahead of work stamped in the same instant. + items.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + + const blocks: string[] = []; + if (input.includeHeader) blocks.push(formatHeader(input)); + blocks.push(`# ${thread.title}`); + let speaker: "user" | "assistant" | null = null; + let workLines: string[] = []; + const flushWork = () => { + if (workLines.length === 0) return; + blocks.push(workLines.join("\n")); + workLines = []; + }; + for (const item of items) { + const itemSpeaker = item.kind === "user" ? "user" : "assistant"; + if (itemSpeaker !== speaker) { + flushWork(); + blocks.push(itemSpeaker === "user" ? "## User" : "## Assistant"); + speaker = itemSpeaker; + } + if (item.kind === "work") { + workLines.push(formatWorkEntry(item.entry)); + continue; + } + flushWork(); + blocks.push(item.kind === "plan" ? `### Plan\n\n${item.markdown.trim()}` : item.text); + } + flushWork(); + + return { + markdown: `${blocks.join("\n\n")}\n`, + messageCount, + toolCallCount: workEntries.length, + }; +} + +/** A filesystem-safe `.md` name from the thread title. */ +export function transcriptFileName(title: string): string { + const slug = title + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) + .replace(/-+$/, ""); + return `${slug || "transcript"}.md`; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 29751f885daae..bf448fe0b5e8c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -19,6 +19,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { CustomSnoozeDialogHost } from "../components/CustomSnoozeDialog"; +import { TranscriptExportDialogHost } from "../components/TranscriptExportDialog"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; import { FirstRunGate } from "../components/onboarding/FirstRunGate"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; @@ -236,6 +237,7 @@ function RootRouteView() { + diff --git a/docs/fork-differences.md b/docs/fork-differences.md index deb1706b00137..6680ef2f1b03a 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -385,6 +385,22 @@ Code: `bootstrap.deferTurn` in `packages/contracts/src/orchestration.ts`, handle `apps/web/src/components/ChatView.tsx` and the pill in `ComposerPrimaryActions.tsx`. User guide: [thread-sidebar.md](./user/thread-sidebar.md#start-a-thread). +## Export a transcript + +On web and desktop, **Export transcript…** in a thread's menu (sidebar row, sidebar tab row, chat +header) or the command palette opens a dialog that previews the thread as Markdown. **Concise** keeps +the prompts and replies; **Full** adds the work log (tool calls, commands, file edits) and proposed +plans in time order. Reasoning is always left out. **Header** adds front matter with the project, +branch, provider, model, and dates. **Save…** writes a `.md` file on the device running the client: +the native save dialog on desktop, the browser's save picker in Chromium, and a download elsewhere. +**Copy** puts the same Markdown on the clipboard. The dialog loads the whole thread over HTTP, so it +works for threads that are not open. Mobile does not offer it. + +Code: [`threadTranscript.ts`](../apps/web/src/lib/threadTranscript.ts), +[`TranscriptExportDialog.tsx`](../apps/web/src/components/TranscriptExportDialog.tsx), and +`saveTextFile` in [`window.ts`](../apps/desktop/src/ipc/methods/window.ts). User guide: +[thread-sidebar.md](./user/thread-sidebar.md#export-a-transcript). + ## Desktop mock-update loop A `Makefile` at the repository root drives a local auto-update test loop for the desktop app: diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 1bcbe8d377010..e5d6f86bc2752 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -179,6 +179,15 @@ and copying a thread reference. A copied reference uses the thread's pull reques link when available, otherwise its thread ID. See [keybindings](./keybindings.md) for custom configuration. +## Export a transcript + +On web and desktop, right-click a thread or tab and choose **Export transcript…**, or run +**Export transcript** from the command palette, to bring a conversation into another app. Choose +**Concise** for just the prompts and replies, or **Full** to also include the commands, tool calls, +file edits, and plans. Turn off **Header** to drop the project, branch, and model details at the +top. **Save…** saves a Markdown file on the device you are using, even when the thread runs on a +remote server; **Copy** puts the same Markdown on your clipboard. + ## Inspect agent work On web and desktop, use **Agents** to follow work delegated to subagents. diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index da1156cc83e07..9ed03f4508c64 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -71,6 +71,24 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError; +/** + * One full thread snapshot for callers outside the thread state machine, such + * as transcript export. Relay signing and remote authorization apply when the + * runtime provides them. + */ +export const loadFullThreadSnapshot = Effect.fn("clientRuntime.state.loadFullThreadSnapshot")( + function* (prepared: PreparedConnection, threadId: ThreadId) { + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization); + return yield* fetchEnvironmentThreadSnapshot({ + prepared, + threadId, + signer, + remoteAuthorization, + }); + }, +); + /** * Loads a thread's detail snapshot over HTTP, returning `Option.none()` when it * cannot be loaded (so the caller falls back to the socket-embedded snapshot). diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 700e5184cf83c..93f7a6b88578d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -538,6 +538,17 @@ export const PickedThemeFileSchema = Schema.Struct({ text: Schema.String, }); +/** A text file the renderer asks the desktop shell to save through the native save dialog. */ +export interface SaveTextFileInput { + defaultFileName: string; + contents: string; +} + +export const SaveTextFileInputSchema = Schema.Struct({ + defaultFileName: Schema.String, + contents: Schema.String, +}); + export interface DesktopWslDistro { name: string; isDefault: boolean; @@ -1207,6 +1218,14 @@ export interface DesktopBridge { * web callers fall back to a plain file input. */ pickThemeFiles?: () => Promise; + /** + * Native save dialog, then writes `contents` to the chosen path. Resolves the + * saved path, or null when cancelled. Optional: older desktop shells lack it, + * and callers fall back to a browser download. + */ + saveTextFile?: (input: SaveTextFileInput) => Promise; + /** Reveals a file saved by `saveTextFile` in the OS file manager. */ + showSavedFileInFolder?: (path: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], From 7afee626e6bc4d4b858656f6f44269016870f7f5 Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Wed, 30 Sep 2026 16:04:28 +0100 Subject: [PATCH 2/2] feat(web): pick which chat tab a transcript exports Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/TranscriptExportDialog.tsx | 89 +++++++++++++------ docs/fork-differences.md | 3 +- docs/user/thread-sidebar.md | 3 +- 3 files changed, 67 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/TranscriptExportDialog.tsx b/apps/web/src/components/TranscriptExportDialog.tsx index ef06933019d24..8614fb7f66f12 100644 --- a/apps/web/src/components/TranscriptExportDialog.tsx +++ b/apps/web/src/components/TranscriptExportDialog.tsx @@ -1,10 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { loadFullThreadSnapshot } from "@t3tools/client-runtime/state/threads"; -import type { OrchestrationThread, ScopedThreadRef } from "@t3tools/contracts"; +import type { OrchestrationThread, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { CopyIcon, DownloadIcon } from "lucide-react"; import { Atom } from "effect/unstable/reactivity"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { runtime } from "~/lib/runtime"; @@ -18,6 +18,7 @@ import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useProject } from "~/state/entities"; import { readPreparedConnection } from "~/state/session"; +import { useThreadTabGroup } from "./chat/ThreadTabs"; import { Button } from "./ui/button"; import { Dialog, @@ -28,6 +29,7 @@ import { DialogPopup, DialogTitle, } from "./ui/dialog"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Spinner } from "./ui/spinner"; import { Switch } from "./ui/switch"; import { toastManager } from "./ui/toast"; @@ -72,34 +74,31 @@ type LoadState = | { readonly status: "ready"; readonly thread: OrchestrationThread } | { readonly status: "failed"; readonly message: string }; +/** Full snapshots by thread, loaded on first view so switching tabs back is instant. */ function useFullThread(threadRef: ScopedThreadRef): LoadState { - // The host keys this dialog by thread, so the ref is fixed for its lifetime. - const [state, setState] = useState(() => - readPreparedConnection(threadRef.environmentId) === null - ? { status: "failed", message: "This thread's environment is not connected." } - : { status: "loading" }, - ); + const [loaded, setLoaded] = useState>(() => new Map()); + const requested = useRef(new Set()); + const connected = readPreparedConnection(threadRef.environmentId) !== null; useEffect(() => { const prepared = readPreparedConnection(threadRef.environmentId); - if (prepared === null) return; - let active = true; - void runtime.runPromise(loadFullThreadSnapshot(prepared, threadRef.threadId)).then( - (snapshot) => { - if (active) setState({ status: "ready", thread: snapshot.thread }); - }, - (error: unknown) => { - if (!active) return; - setState({ + const { threadId } = threadRef; + if (prepared === null || requested.current.has(threadId)) return; + requested.current.add(threadId); + const settle = (state: LoadState) => + setLoaded((previous) => new Map(previous).set(threadId, state)); + void runtime.runPromise(loadFullThreadSnapshot(prepared, threadId)).then( + (snapshot) => settle({ status: "ready", thread: snapshot.thread }), + (error: unknown) => + settle({ status: "failed", message: error instanceof Error ? error.message : "Could not load this thread.", - }); - }, + }), ); - return () => { - active = false; - }; }, [threadRef]); - return state; + if (!connected) { + return { status: "failed", message: "This thread's environment is not connected." }; + } + return loaded.get(threadRef.threadId) ?? { status: "loading" }; } function revealLabel(): string { @@ -114,7 +113,16 @@ function formatTokenEstimate(characters: number): string { return tokens < 1000 ? `~${tokens} tokens` : `~${(tokens / 1000).toFixed(1)}k tokens`; } -function TranscriptExportDialog({ threadRef }: { threadRef: ScopedThreadRef }) { +function TranscriptExportDialog({ threadRef: openedRef }: { threadRef: ScopedThreadRef }) { + const { environmentId } = openedRef; + // A thread with sibling chat tabs can export any of them; it starts on the one it was opened from. + const tabGroup = useThreadTabGroup(environmentId, openedRef.threadId); + const tabs = tabGroup && tabGroup.tabs.length > 1 ? tabGroup.tabs : null; + const [selectedThreadId, setSelectedThreadId] = useState(openedRef.threadId); + const threadRef = useMemo( + () => scopeThreadRef(environmentId, selectedThreadId), + [environmentId, selectedThreadId], + ); const load = useFullThread(threadRef); const thread = load.status === "ready" ? load.thread : null; const project = useProject( @@ -140,6 +148,8 @@ function TranscriptExportDialog({ threadRef }: { threadRef: ScopedThreadRef }) { : null, [thread, project?.title, detail, includeHeader], ); + const title = + thread?.title ?? tabs?.find((tab) => tab.threadId === selectedThreadId)?.title ?? null; const fileName = thread ? transcriptFileName(thread.title) : null; const { copyToClipboard } = useCopyToClipboard({ @@ -223,13 +233,40 @@ function TranscriptExportDialog({ threadRef }: { threadRef: ScopedThreadRef }) { Export transcript - {[thread?.title, project?.title].filter(Boolean).join(" · ") || "Loading thread…"} + {[title, project?.title].filter(Boolean).join(" · ") || "Loading thread…"}
+ {tabs ? ( + + ) : null}