Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 49 additions & 5 deletions packages/app/src/pages/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2168,6 +2206,9 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void exportSessionTrace()}>
<DropdownMenu.ItemLabel>{language.t("session.exportTrace")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show
when={sync().session.get(id)?.time?.archived}
fallback={
Expand Down Expand Up @@ -2248,6 +2289,9 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => void exportSessionTrace()}>
{language.t("session.exportTrace")}
</MenuV2.Item>
<Show
when={sync().session.get(id)?.time?.archived}
fallback={
Expand Down
10 changes: 7 additions & 3 deletions packages/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import { stat, writeFile } from "node:fs/promises"
import { basename, join } from "node:path"
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
Expand Down Expand Up @@ -187,13 +187,17 @@ export function registerIpcHandlers(deps: Deps) {

ipcMain.handle(
"save-file-picker",
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
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
},
)

Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/preload/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export type ElectronAPI = {
readPickedFile: (token: string, path: string) => Promise<ArrayBuffer>
releasePickedFiles: (token: string) => Promise<void>
getPathForFile: (file: File) => string
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
saveFilePicker: (opts?: { title?: string; defaultPath?: string; content?: string }) => Promise<string | null>
openExternal: (url: string) => void
openLocalFile: (url: string) => void
openPath: (path: string, app?: string) => Promise<void>
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/src/renderer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
},

Expand Down
143 changes: 141 additions & 2 deletions packages/session-ui/src/components/build-trace.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string, PartType[]> = {
Expand Down Expand Up @@ -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<string, PartType[]> = {
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<string, PartType[]> = {
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<string, PartType[]> = {
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<string, PartType[]> = {
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<string, PartType[]> = {
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<string, PartType[]> = {
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<string, PartType[]> = {
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<string, PartType[]> = {
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<string, PartType[]> = {
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<string, PartType[]> = {
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<string, PartType[]> = {
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<string, PartType[]> = {
u1: [textPart("p0", "Hello?")],
}
const result = buildSessionTrace(messages, (id) => parts[id] ?? [])
expect(result).toBe("You: Hello?")
})
})
57 changes: 56 additions & 1 deletion packages/session-ui/src/components/build-trace.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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: <userText>` 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
Expand All @@ -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: <input>` 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")
}
Loading
Loading