From f1adabcddc2fac6089af5c1e86e21d4dd93db979 Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Thu, 6 Aug 2026 11:50:33 +1000
Subject: [PATCH] feat(app): export session as json from ui (#40781)
---
.../session/session-context-tab.tsx | 36 ++++++++++-
packages/app/src/i18n/en.ts | 9 +++
.../session/timeline/message-timeline.tsx | 30 +++++++++
.../pages/session/use-session-commands.tsx | 36 ++++++++++-
packages/app/src/utils/session-export.test.ts | 61 +++++++++++++++++++
packages/app/src/utils/session-export.ts | 61 +++++++++++++++++++
6 files changed, 231 insertions(+), 2 deletions(-)
create mode 100644 packages/app/src/utils/session-export.test.ts
create mode 100644 packages/app/src/utils/session-export.ts
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 25ad5ab230c..c08c202fa2e 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -5,12 +5,15 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { findLast } from "@opencode-ai/core/util/array"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
+import { Button } from "@opencode-ai/ui/button"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
+import { showToast } from "@/utils/toast"
+import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
@@ -220,6 +223,31 @@ export function SessionContextTab() {
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[]
+ const exportSession = async () => {
+ const sessionID = params.id
+ if (!sessionID) return
+ try {
+ const data = await fetchSessionExport({
+ sessionID,
+ client: sdk().client,
+ })
+ const filename = sessionExportFilename(data.info)
+ downloadSessionExport(filename, data)
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: language.t("toast.session.export.success.title"),
+ description: language.t("toast.session.export.success.description", { filename }),
+ })
+ } catch (err) {
+ showToast({
+ variant: "error",
+ title: language.t("toast.session.export.failed.title"),
+ description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
+ })
+ }
+ }
+
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
@@ -328,7 +356,13 @@ export function SessionContextTab() {
-
{language.t("context.rawMessages.title")}
+
+
{language.t("context.rawMessages.title")}
+
+
{(message) => (
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index c37c9c21161..26720c8ac0d 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -95,6 +95,8 @@ export const dict = {
"command.session.share.description": "Share this session and copy the URL to clipboard",
"command.session.unshare": "Unshare session",
"command.session.unshare.description": "Stop sharing this session",
+ "command.session.export": "Export session",
+ "command.session.export.description": "Export the full session transcript as JSON",
"palette.search.placeholder": "Search files, commands, and sessions",
"palette.search.placeholder.home": "Search commands and sessions",
@@ -489,6 +491,7 @@ export const dict = {
"context.systemPrompt.title": "System Prompt",
"context.rawMessages.title": "Raw messages",
+ "context.export.session": "Export session",
"context.stats.session": "Session",
"context.stats.messages": "Messages",
@@ -568,6 +571,11 @@ export const dict = {
"toast.session.unshare.failed.title": "Failed to unshare session",
"toast.session.unshare.failed.description": "An error occurred while unsharing the session",
+ "toast.session.export.success.title": "Session exported",
+ "toast.session.export.success.description": "Saved session to {{filename}}",
+ "toast.session.export.failed.title": "Failed to export session",
+ "toast.session.export.failed.description": "An error occurred while exporting the session",
+
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
@@ -802,6 +810,7 @@ export const dict = {
"common.moreOptions": "More options",
"common.learnMore": "Learn more",
"common.rename": "Rename",
+ "common.export": "Export",
"common.reset": "Reset",
"common.archive": "Archive",
"common.delete": "Delete",
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx
index 7d22ce5e166..e69623179fa 100644
--- a/packages/app/src/pages/session/timeline/message-timeline.tsx
+++ b/packages/app/src/pages/session/timeline/message-timeline.tsx
@@ -53,6 +53,7 @@ import type {
UserMessage,
} from "@opencode-ai/sdk/v2"
import { showToast } from "@/utils/toast"
+import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/session-ui/session-diff"
@@ -806,6 +807,29 @@ export function MessageTimeline(props: {
navigate(`/${params.dir}/session`)
}
+ const exportSession = async (sessionID: string) => {
+ try {
+ const data = await fetchSessionExport({
+ sessionID,
+ client: sdk().client,
+ })
+ const filename = sessionExportFilename(data.info)
+ downloadSessionExport(filename, data)
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: language.t("toast.session.export.success.title"),
+ description: language.t("toast.session.export.success.description", { filename }),
+ })
+ } catch (err) {
+ showToast({
+ variant: "error",
+ title: language.t("toast.session.export.failed.title"),
+ description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
+ })
+ }
+ }
+
const archiveSession = async (sessionID: string) => {
const session = sync().session.get(sessionID)
if (!session) return
@@ -1564,6 +1588,9 @@ export function MessageTimeline(props: {
+ exportSession(id)}>
+ {language.t("common.export")}
+
void archiveSession(id)}>
{language.t("common.archive")}
@@ -1635,6 +1662,9 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
+ exportSession(id)}>
+ {language.t("common.export")}...
+
void archiveSession(id)}>
{language.t("common.archive")}
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index 12dd96a5e66..cfc302e7753 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -12,10 +12,11 @@ import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast"
+import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { findLast } from "@opencode-ai/core/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
import { extractPromptFromParts } from "@/utils/prompt"
-import { UserMessage } from "@opencode-ai/sdk/v2"
+import { Message, Part, UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
@@ -231,6 +232,31 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
)
}
+ const exportSession = async () => {
+ const sessionID = params.id
+ if (!sessionID) return
+ try {
+ const data = await fetchSessionExport({
+ sessionID,
+ client: sdk().client,
+ })
+ const filename = sessionExportFilename(data.info)
+ downloadSessionExport(filename, data)
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: language.t("toast.session.export.success.title"),
+ description: language.t("toast.session.export.success.description", { filename }),
+ })
+ } catch (err) {
+ showToast({
+ variant: "error",
+ title: language.t("toast.session.export.failed.title"),
+ description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
+ })
+ }
+ }
+
const openFile = () => {
void openDialog(
() => import("@/components/dialog-select-file"),
@@ -458,6 +484,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: fork,
}),
+ sessionCommand({
+ id: "session.export",
+ title: language.t("command.session.export"),
+ description: language.t("command.session.export.description"),
+ slash: "export",
+ disabled: !params.id,
+ onSelect: exportSession,
+ }),
]
const fileCmds = () => {
diff --git a/packages/app/src/utils/session-export.test.ts b/packages/app/src/utils/session-export.test.ts
new file mode 100644
index 00000000000..ff18a16b7fa
--- /dev/null
+++ b/packages/app/src/utils/session-export.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test"
+import { fetchSessionExport, sessionExportFilename } from "./session-export"
+import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
+
+describe("sessionExportFilename", () => {
+ test("generates filename from title", () => {
+ expect(sessionExportFilename({ id: "ses_123", title: "Clone PR in worktree from fork" })).toBe(
+ "clone-pr-in-worktree-from-fork.json",
+ )
+ })
+
+ test("generates filename from slug when title missing", () => {
+ expect(sessionExportFilename({ id: "ses_123", slug: "my-session-slug" })).toBe("my-session-slug.json")
+ })
+
+ test("falls back to id when title and slug are empty", () => {
+ expect(sessionExportFilename({ id: "ses_123" })).toBe("ses_123.json")
+ })
+})
+
+describe("fetchSessionExport", () => {
+ test("fetches full transcript from client", async () => {
+ const session = { id: "ses_1", title: "Test Session" } as Session
+ const msg = { id: "msg_1", role: "user" } as Message
+ const part = { id: "prt_1", type: "text", text: "hello" } as Part
+ const messages = [{ info: msg, parts: [part] }]
+
+ const client = {
+ session: {
+ get: async () => ({ data: session }),
+ messages: async () => ({ data: messages }),
+ },
+ }
+
+ const result = await fetchSessionExport({
+ sessionID: "ses_1",
+ client,
+ })
+
+ expect(result).toEqual({
+ info: session,
+ messages,
+ })
+ })
+
+ test("throws when session not found", async () => {
+ const client = {
+ session: {
+ get: async () => ({ data: null }),
+ messages: async () => ({ data: [] }),
+ },
+ }
+
+ expect(
+ fetchSessionExport({
+ sessionID: "ses_missing",
+ client,
+ }),
+ ).rejects.toThrow("Session not found: ses_missing")
+ })
+})
diff --git a/packages/app/src/utils/session-export.ts b/packages/app/src/utils/session-export.ts
new file mode 100644
index 00000000000..6eb9f9ab6a7
--- /dev/null
+++ b/packages/app/src/utils/session-export.ts
@@ -0,0 +1,61 @@
+import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
+
+// Matches the exact `{ info, messages: [{ info, parts }] }` structure produced by `opencode export` CLI
+export type SessionExportData = {
+ info: Session
+ messages: {
+ info: Message
+ parts: Part[]
+ }[]
+}
+
+export type SessionExportClient = {
+ session: {
+ get: (input: { sessionID: string }) => Promise<{ data?: Session | null }>
+ messages: (input: { sessionID: string }) => Promise<{ data?: SessionExportData["messages"] | null }>
+ }
+}
+
+export async function fetchSessionExport(input: {
+ sessionID: string
+ client: SessionExportClient
+}): Promise {
+ const [sessionRes, messagesRes] = await Promise.all([
+ input.client.session.get({ sessionID: input.sessionID }),
+ input.client.session.messages({ sessionID: input.sessionID }),
+ ])
+
+ if (!sessionRes?.data) {
+ throw new Error(`Session not found: ${input.sessionID}`)
+ }
+ if (!messagesRes?.data) {
+ throw new Error(`Failed to load messages for session: ${input.sessionID}`)
+ }
+
+ return {
+ info: sessionRes.data,
+ messages: messagesRes.data,
+ }
+}
+
+export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) {
+ const name = session.title || session.slug || session.id
+ const clean = name
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/gi, "-")
+ .replace(/^-+|-+$/g, "")
+ return `${clean || session.id}.json`
+}
+
+export function downloadSessionExport(filename: string, data: unknown) {
+ const json = JSON.stringify(data, null, 2)
+ const blob = new Blob([json], { type: "application/json" })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+}