feat(app): export session as json from ui (#40781)
This commit is contained in:
@@ -5,12 +5,15 @@ import { checksum } from "@opencode-ai/core/util/encode"
|
|||||||
import { findLast } from "@opencode-ai/core/util/array"
|
import { findLast } from "@opencode-ai/core/util/array"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
import { File } from "@opencode-ai/session-ui/file"
|
import { File } from "@opencode-ai/session-ui/file"
|
||||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
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 { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
@@ -220,6 +223,31 @@ export function SessionContextTab() {
|
|||||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||||
] satisfies { label: string; value: () => JSX.Element }[]
|
] 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 scroll: HTMLDivElement | undefined
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
@@ -328,7 +356,13 @@ export function SessionContextTab() {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
|
||||||
|
<Button size="small" variant="ghost" class="gap-1.5 px-2 text-text-weak hover:text-text-base" onClick={exportSession}>
|
||||||
|
<Icon name="download" size="small" />
|
||||||
|
<span>{language.t("context.export.session")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Accordion multiple>
|
<Accordion multiple>
|
||||||
<For each={messages()}>
|
<For each={messages()}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const dict = {
|
|||||||
"command.session.share.description": "Share this session and copy the URL to clipboard",
|
"command.session.share.description": "Share this session and copy the URL to clipboard",
|
||||||
"command.session.unshare": "Unshare session",
|
"command.session.unshare": "Unshare session",
|
||||||
"command.session.unshare.description": "Stop sharing this 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": "Search files, commands, and sessions",
|
||||||
"palette.search.placeholder.home": "Search commands and sessions",
|
"palette.search.placeholder.home": "Search commands and sessions",
|
||||||
@@ -489,6 +491,7 @@ export const dict = {
|
|||||||
|
|
||||||
"context.systemPrompt.title": "System Prompt",
|
"context.systemPrompt.title": "System Prompt",
|
||||||
"context.rawMessages.title": "Raw messages",
|
"context.rawMessages.title": "Raw messages",
|
||||||
|
"context.export.session": "Export session",
|
||||||
|
|
||||||
"context.stats.session": "Session",
|
"context.stats.session": "Session",
|
||||||
"context.stats.messages": "Messages",
|
"context.stats.messages": "Messages",
|
||||||
@@ -568,6 +571,11 @@ export const dict = {
|
|||||||
"toast.session.unshare.failed.title": "Failed to unshare session",
|
"toast.session.unshare.failed.title": "Failed to unshare session",
|
||||||
"toast.session.unshare.failed.description": "An error occurred while unsharing the 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.session.listFailed.title": "Failed to load sessions for {{project}}",
|
||||||
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
||||||
|
|
||||||
@@ -802,6 +810,7 @@ export const dict = {
|
|||||||
"common.moreOptions": "More options",
|
"common.moreOptions": "More options",
|
||||||
"common.learnMore": "Learn more",
|
"common.learnMore": "Learn more",
|
||||||
"common.rename": "Rename",
|
"common.rename": "Rename",
|
||||||
|
"common.export": "Export",
|
||||||
"common.reset": "Reset",
|
"common.reset": "Reset",
|
||||||
"common.archive": "Archive",
|
"common.archive": "Archive",
|
||||||
"common.delete": "Delete",
|
"common.delete": "Delete",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import type {
|
|||||||
UserMessage,
|
UserMessage,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||||
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
import { normalize } from "@opencode-ai/session-ui/session-diff"
|
||||||
@@ -806,6 +807,29 @@ export function MessageTimeline(props: {
|
|||||||
navigate(`/${params.dir}/session`)
|
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 archiveSession = async (sessionID: string) => {
|
||||||
const session = sync().session.get(sessionID)
|
const session = sync().session.get(sessionID)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
@@ -1564,6 +1588,9 @@ export function MessageTimeline(props: {
|
|||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
|
<DropdownMenu.Item onSelect={() => exportSession(id)}>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -1635,6 +1662,9 @@ export function MessageTimeline(props: {
|
|||||||
{language.t("session.share.action.share")}...
|
{language.t("session.share.action.share")}...
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
|
<MenuV2.Item onSelect={() => exportSession(id)}>
|
||||||
|
{language.t("common.export")}...
|
||||||
|
</MenuV2.Item>
|
||||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||||
{language.t("common.archive")}
|
{language.t("common.archive")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import { useSettings } from "@/context/settings"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { findLast } from "@opencode-ai/core/util/array"
|
import { findLast } from "@opencode-ai/core/util/array"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
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 { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionOwnership } from "./session-ownership"
|
import { createSessionOwnership } from "./session-ownership"
|
||||||
import { useLocal } from "@/context/local"
|
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 = () => {
|
const openFile = () => {
|
||||||
void openDialog(
|
void openDialog(
|
||||||
() => import("@/components/dialog-select-file"),
|
() => import("@/components/dialog-select-file"),
|
||||||
@@ -458,6 +484,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
disabled: !params.id || visibleUserMessages().length === 0,
|
disabled: !params.id || visibleUserMessages().length === 0,
|
||||||
onSelect: fork,
|
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 = () => {
|
const fileCmds = () => {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<SessionExportData> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user