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..8614fb7f66f12 --- /dev/null +++ b/apps/web/src/components/TranscriptExportDialog.tsx @@ -0,0 +1,322 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { loadFullThreadSnapshot } from "@t3tools/client-runtime/state/threads"; +import type { OrchestrationThread, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { CopyIcon, DownloadIcon } from "lucide-react"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect, useMemo, useRef, 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 { useThreadTabGroup } from "./chat/ThreadTabs"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + 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"; +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 }; + +/** Full snapshots by thread, loaded on first view so switching tabs back is instant. */ +function useFullThread(threadRef: ScopedThreadRef): LoadState { + const [loaded, setLoaded] = useState>(() => new Map()); + const requested = useRef(new Set()); + const connected = readPreparedConnection(threadRef.environmentId) !== null; + useEffect(() => { + const prepared = readPreparedConnection(threadRef.environmentId); + 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.", + }), + ); + }, [threadRef]); + if (!connected) { + return { status: "failed", message: "This thread's environment is not connected." }; + } + return loaded.get(threadRef.threadId) ?? { status: "loading" }; +} + +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: 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( + 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 title = + thread?.title ?? tabs?.find((tab) => tab.threadId === selectedThreadId)?.title ?? null; + 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 + + + {[title, project?.title].filter(Boolean).join(" · ") || "Loading thread…"} + + + + +
+
+ {tabs ? ( + + ) : null} + { + 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..23eb8630d5802 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -385,6 +385,23 @@ 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. A thread with +several chat tabs gets a tab picker, starting on the tab it was opened from. **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..d5d3ba68def29 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -179,6 +179,16 @@ 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. If the +chat has more than one tab, pick which tab to export at the top of the preview. 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[],