diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 671219107..67a759629 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -16,7 +16,7 @@ type OpenAttachmentPickerOptions = { extensions?: string[] defaultPath?: string } -type SaveFilePickerOptions = { title?: string; defaultPath?: string } +type SaveFilePickerOptions = { title?: string; defaultPath?: string; content?: string } type PlatformName = "web" | "desktop" type DesktopOS = "macos" | "windows" | "linux" diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 70d504e02..32dcd9b0e 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -835,6 +835,7 @@ export const dict = { "session.share.action.view": "View", "session.share.copy.copied": "Copied", "session.share.copy.copyLink": "Copy link", + "session.exportTrace": "Export trace", "lsp.tooltip.none": "No LSP servers", "lsp.label.connected": "{{count}} LSP", diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 5913c7a4d..cd1879bb7 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -42,7 +42,7 @@ import { type UserActions, } from "@opencode-ai/session-ui/message-part" import { readPartText, settledChunkBoundary } from "@opencode-ai/session-ui/message-part-text" -import { buildTrace } from "@opencode-ai/session-ui/build-trace" +import { buildTrace, buildSessionTrace } from "@opencode-ai/session-ui/build-trace" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" @@ -480,13 +480,51 @@ export function MessageTimeline(props: { // Copy the full assistant trace for a turn to the clipboard. const copyTraceForTurn = (userMessageID: string) => { const msgs = sessionMessages() - const start = msgs.findIndex((m) => m.id === userMessageID) - if (start === -1) return - const assistantMsgs = msgs.slice(start + 1).filter((m): m is AssistantMessage => m.role === "assistant") - const content = buildTrace(assistantMsgs, getMsgParts) + const userParts = getMsgParts(userMessageID) + const userTextPart = userParts.find( + (p): p is TextPart => p.type === "text" && !(p as TextPart).synthetic, + ) + const assistantMsgs = msgs.filter( + (m): m is AssistantMessage => m.role === "assistant" && m.parentID === userMessageID, + ) + const content = buildTrace(assistantMsgs, getMsgParts, userTextPart?.text) if (!content) return if (!writeClipboardViaBridge(content)) void navigator.clipboard?.writeText(content) } + + // Export the full session trace to a file. + const exportSessionTrace = async () => { + const msgs = sessionMessages() + const content = buildSessionTrace(msgs, getMsgParts) + if (!content) return + const raw = titleValue() || "session" + const slug = raw + .trim() + .toLowerCase() + .replace(/[\s_]+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + const date = new Date().toISOString().slice(0, 10) + const filename = `trace-${slug || "session"}-${date}.md` + + if (platform.saveFilePickerDialog) { + await platform.saveFilePickerDialog({ + title: language.t("session.exportTrace"), + defaultPath: filename, + content, + }) + } else { + const blob = new Blob([content], { type: "text/markdown" }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) + } + } + /** True when at least one AssistantPart ROW exists in the projected timeline * for this turn — meaning renderable, settled output is visible. Reasoning * parts withheld while streaming do NOT count (they produce no row until @@ -2168,6 +2206,9 @@ export function MessageTimeline(props: { + void exportSessionTrace()}> + {language.t("session.exportTrace")} + + void exportSessionTrace()}> + {language.t("session.exportTrace")} + { + async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string; content?: string }) => { const result = await dialog.showSaveDialog({ title: opts?.title ?? "Save file", defaultPath: opts?.defaultPath, }) if (result.canceled) return null - return result.filePath ?? null + const filePath = result.filePath ?? null + if (filePath && opts?.content) { + await writeFile(filePath, opts.content, "utf-8") + } + return filePath }, ) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index fbf58f1fd..69aafead3 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -87,7 +87,7 @@ export type ElectronAPI = { readPickedFile: (token: string, path: string) => Promise releasePickedFiles: (token: string) => Promise getPathForFile: (file: File) => string - saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise + saveFilePicker: (opts?: { title?: string; defaultPath?: string; content?: string }) => Promise openExternal: (url: string) => void openLocalFile: (url: string) => void openPath: (path: string, app?: string) => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index e1ff8fbfc..6c18240d5 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -206,6 +206,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { return window.api.saveFilePicker({ title: opts?.title ?? t("desktop.dialog.saveFile"), defaultPath: opts?.defaultPath, + content: opts?.content, }) }, diff --git a/packages/session-ui/src/components/build-trace.test.ts b/packages/session-ui/src/components/build-trace.test.ts index 4a3a13a69..6f0853b1d 100644 --- a/packages/session-ui/src/components/build-trace.test.ts +++ b/packages/session-ui/src/components/build-trace.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { buildTrace } from "./build-trace" -import type { AssistantMessage, Part as PartType } from "@opencode-ai/sdk/v2" +import { buildTrace, buildSessionTrace } from "./build-trace" +import type { AssistantMessage, Message, Part as PartType, UserMessage } from "@opencode-ai/sdk/v2" function msg(id: string): AssistantMessage { return { @@ -66,6 +66,17 @@ function skipPart(id: string, tool: string): PartType { } as PartType } +function userMsg(id: string): UserMessage { + return { + id, + sessionID: "s1", + role: "user", + time: { created: 1000 }, + agent: "default", + model: { providerID: "p1", modelID: "m1" }, + } as UserMessage +} + describe("buildTrace", () => { test("concatenates text parts from a single message", () => { const parts: Record = { @@ -154,4 +165,132 @@ describe("buildTrace", () => { const result = buildTrace([msg("msg1")], (id) => parts[id] ?? []) expect(result).toBe("some output") }) + + test("prepends user text with You: prefix when provided", () => { + const parts: Record = { + msg1: [textPart("p1", "The answer is 42")], + } + const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [], "What is the meaning of life?") + expect(result).toBe("You: What is the meaning of life?\n\nThe answer is 42") + }) + + test("no user prefix for empty userText", () => { + const parts: Record = { + msg1: [textPart("p1", "The answer")], + } + const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [], "") + expect(result).toBe("The answer") + }) + + test("no user prefix for whitespace-only userText", () => { + const parts: Record = { + msg1: [textPart("p1", "The answer")], + } + const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [], " ") + expect(result).toBe("The answer") + }) + + test("no user prefix when userText is undefined", () => { + const parts: Record = { + msg1: [textPart("p1", "The answer")], + } + const result = buildTrace([msg("msg1")], (id) => parts[id] ?? [], undefined) + expect(result).toBe("The answer") + }) + + test("user text with only assistant trace (backward compatible)", () => { + const parts: Record = { + msg1: [textPart("p1", "Hello"), bashPart("p2", "ls", "file.txt")], + } + const result = buildTrace([msg("msg1")], (id) => parts[id] ?? []) + expect(result).toBe("Hello\n\n$ ls\nfile.txt") + }) +}) + +describe("buildSessionTrace", () => { + test("single turn with user and assistant", () => { + const messages: Message[] = [userMsg("u1"), msg("a1")] + const parts: Record = { + u1: [textPart("p0", "Hello")], + a1: [textPart("p1", "Hi there")], + } + const result = buildSessionTrace(messages, (id) => parts[id] ?? []) + expect(result).toBe("You: Hello\n\nHi there") + }) + + test("multiple turns in order", () => { + const messages: Message[] = [userMsg("u1"), msg("a1"), userMsg("u2"), msg("a2")] + const parts: Record = { + u1: [textPart("p0", "First question")], + a1: [textPart("p1", "First answer")], + u2: [textPart("p2", "Second question")], + a2: [textPart("p3", "Second answer")], + } + const result = buildSessionTrace(messages, (id) => parts[id] ?? []) + expect(result).toBe("You: First question\n\nFirst answer\n\nYou: Second question\n\nSecond answer") + }) + + test("returns empty string for no messages", () => { + const result = buildSessionTrace([], () => []) + expect(result).toBe("") + }) + + test("user message with no assistant response", () => { + const messages: Message[] = [userMsg("u1"), userMsg("u2"), msg("a1")] + const parts: Record = { + u1: [textPart("p0", "First")], + u2: [textPart("p1", "Second")], + a1: [textPart("p2", "Response")], + } + const result = buildSessionTrace(messages, (id) => parts[id] ?? []) + expect(result).toBe("You: First\n\nYou: Second\n\nResponse") + }) + + test("user message with no text part produces no prefix", () => { + const messages: Message[] = [userMsg("u1"), msg("a1")] + const parts: Record = { + u1: [], + a1: [textPart("p1", "Response")], + } + const result = buildSessionTrace(messages, (id) => parts[id] ?? []) + expect(result).toBe("Response") + }) + + test("skips synthetic user text parts", () => { + const syntheticPart = { + id: "p0", + sessionID: "s1", + messageID: "u1", + type: "text", + text: "system prompt", + synthetic: true, + } as PartType + const messages: Message[] = [userMsg("u1"), msg("a1")] + const parts: Record = { + u1: [syntheticPart], + a1: [textPart("p1", "Response")], + } + const result = buildSessionTrace(messages, (id) => parts[id] ?? []) + expect(result).toBe("Response") + }) + + test("turn with multiple assistant messages", () => { + const messages: Message[] = [userMsg("u1"), msg("a1"), msg("a2")] + const parts: Record = { + u1: [textPart("p0", "Run something")], + a1: [textPart("p1", "Running"), bashPart("p2", "echo hi", "hi")], + a2: [textPart("p3", "Done")], + } + const result = buildSessionTrace(messages, (id) => parts[id] ?? []) + expect(result).toBe("You: Run something\n\nRunning\n\n$ echo hi\nhi\n\nDone") + }) + + test("session with no assistant messages at all", () => { + const messages: Message[] = [userMsg("u1")] + const parts: Record = { + u1: [textPart("p0", "Hello?")], + } + const result = buildSessionTrace(messages, (id) => parts[id] ?? []) + expect(result).toBe("You: Hello?") + }) }) diff --git a/packages/session-ui/src/components/build-trace.ts b/packages/session-ui/src/components/build-trace.ts index 43858217f..4a729df76 100644 --- a/packages/session-ui/src/components/build-trace.ts +++ b/packages/session-ui/src/components/build-trace.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Part as PartType } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Message, Part as PartType, TextPart } from "@opencode-ai/sdk/v2" // Skipped tool types when building the copy-trace content — these are internal // bookkeeping or exploration noise, not user-facing output. @@ -7,13 +7,20 @@ const TRACE_SKIP_TOOLS = new Set(["read", "glob", "grep", "list"]) /** * Build a copyable trace string from an assistant turn's messages and parts. * Concatenates text parts with tool command+output, skipping exploration noise. + * + * When `userText` is provided and non-empty, prepends `You: ` before + * the assistant trace, separated by a blank line. */ export function buildTrace( messages: AssistantMessage[], getParts: (messageID: string) => PartType[], + userText?: string, ): string { const segments: string[] = [] + const trimmedUser = userText?.trim() + if (trimmedUser) segments.push(`You: ${trimmedUser}`) + for (const message of messages) { for (const part of getParts(message.id)) { if (!part) continue @@ -40,3 +47,51 @@ export function buildTrace( return segments.join("\n\n") } + +/** Extract the user-typed text from a user message's parts (first non-synthetic text). */ +function extractUserText(parts: PartType[]): string | undefined { + const textPart = parts.find( + (p): p is TextPart => p.type === "text" && !(p as TextPart).synthetic, + ) + return textPart?.text?.trim() || undefined +} + +/** + * Build a full session trace from all messages in order. + * Groups messages into turns (user → assistant*) and concatenates each turn's + * trace with `You: ` prefixes, separated by blank lines. + */ +export function buildSessionTrace( + messages: Message[], + getParts: (messageID: string) => PartType[], +): string { + const turns: string[] = [] + + let i = 0 + while (i < messages.length) { + const message = messages[i] + if (message.role === "user") { + const userText = extractUserText(getParts(message.id)) + // Collect all subsequent assistant messages for this turn + const assistantMsgs: AssistantMessage[] = [] + i++ + while (i < messages.length && messages[i].role === "assistant") { + assistantMsgs.push(messages[i] as AssistantMessage) + i++ + } + const turnTrace = buildTrace(assistantMsgs, getParts, userText) + if (turnTrace) turns.push(turnTrace) + } else { + // Orphan assistant message without a preceding user message + const assistantMsgs: AssistantMessage[] = [] + while (i < messages.length && messages[i].role === "assistant") { + assistantMsgs.push(messages[i] as AssistantMessage) + i++ + } + const turnTrace = buildTrace(assistantMsgs, getParts) + if (turnTrace) turns.push(turnTrace) + } + } + + return turns.join("\n\n") +} diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index f028d8c54..4d932c801 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -774,6 +774,7 @@ export function partDefaultOpen(part: PartType, shell = false, edit = false) { export function AssistantParts(props: { messages: AssistantMessage[] + userText?: string showAssistantCopyPartID?: string | null turnDurationMs?: number useV2Actions?: boolean @@ -968,13 +969,14 @@ export function AssistantParts(props: { - + ) } function TurnFooter(props: { messages: AssistantMessage[] + userText?: string turnDurationMs?: number working?: boolean }) { @@ -1033,7 +1035,7 @@ function TurnFooter(props: { const handleCopyTrace = async () => { const emptyParts: PartType[] = [] - const content = buildTrace(props.messages, (id) => list(data.store.part?.[id], emptyParts)) + const content = buildTrace(props.messages, (id) => list(data.store.part?.[id], emptyParts), props.userText) if (!content) return if (await writeClipboard(content)) { setCopied(true) diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index 41fed33c0..da8ed7d53 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -3,6 +3,7 @@ import { type SnapshotFileDiff, Message as MessageType, Part as PartType, + type TextPart, } from "@opencode-ai/sdk/v2/client" import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { SessionStatus } from "@opencode-ai/sdk/v2" @@ -236,6 +237,13 @@ export function SessionTurn( return list(data.store.part?.[msg.id], emptyParts) }) + const userText = createMemo(() => { + const p = parts().find( + (p): p is TextPart => p.type === "text" && !(p as TextPart).synthetic, + ) + return p?.text?.trim() || undefined + }) + const compaction = createMemo(() => parts().find((part) => part.type === "compaction")) const diffs = createMemo(() => { @@ -409,6 +417,7 @@ export function SessionTurn(