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(