diff --git a/packages/app/.gitignore b/packages/app/.gitignore
index d699efb38d2..5c034ca65fe 100644
--- a/packages/app/.gitignore
+++ b/packages/app/.gitignore
@@ -1,3 +1,4 @@
src/assets/theme.css
e2e/test-results
e2e/playwright-report
+.tmp/
diff --git a/packages/app/package.json b/packages/app/package.json
index 9f1021e8c3b..764ccf63d3c 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -17,6 +17,7 @@
"test": "bun run test:unit",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
+ "test:screenshot": "bun script/session-screenshot-cli.ts",
"test:e2e": "playwright test",
"test:e2e:local": "bun script/e2e-local.ts",
"test:e2e:ui": "playwright test --ui",
diff --git a/packages/app/script/session-screenshot-cli.ts b/packages/app/script/session-screenshot-cli.ts
new file mode 100644
index 00000000000..289c5f719d4
--- /dev/null
+++ b/packages/app/script/session-screenshot-cli.ts
@@ -0,0 +1,137 @@
+import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
+import path from "node:path"
+import { chromium } from "@playwright/test"
+
+const root = path.resolve(import.meta.dir, "..")
+const out = path.join(root, ".tmp", "session-screenshot-cli")
+const entry = path.join(out, "entry.ts")
+const html = path.join(out, "index.html")
+const mod = path.join(root, "src", "pages", "session", "session-screenshot.ts").replaceAll("\\", "\\\\")
+const arg = process.argv[2]
+const files = arg
+ ? [path.resolve(process.cwd(), arg)]
+ : [
+ path.join(root, "script", "session-screenshot-fixture.json"),
+ path.join(root, "script", "session-screenshot-fixture-real.json"),
+ ]
+const shots = await Promise.all(
+ files.map(async (file) => {
+ const json = await readFile(file, "utf8")
+ return {
+ file,
+ name: `${path.basename(file, ".json")}.png`,
+ json,
+ }
+ }),
+)
+
+const src = `
+import { createSessionScreenshot } from "${mod}"
+
+const shots = ${JSON.stringify(shots)}
+
+try {
+ window.__SHOT__ = []
+ for (const shot of shots) {
+ const input = JSON.parse(shot.json)
+ const blob = await createSessionScreenshot({
+ sessionID: input.sessionID,
+ title: input.title,
+ dir: input.dir,
+ messages: input.messages,
+ parts: (id) => input.parts[id] ?? [],
+ revert: input.revert,
+ })
+
+ window.__SHOT__.push({
+ name: shot.name,
+ data: await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onerror = () => reject(reader.error ?? new Error("read failed"))
+ reader.onload = () => resolve(String(reader.result))
+ reader.readAsDataURL(blob)
+ }),
+ })
+ }
+ document.body.dataset.ready = "true"
+} catch (err) {
+ document.body.dataset.error = err instanceof Error ? err.message : String(err)
+ throw err
+}
+`
+
+const page = `
+
+
+
+
+
+`
+
+await rm(out, { recursive: true, force: true })
+await mkdir(out, { recursive: true })
+await writeFile(entry, src)
+await writeFile(html, page)
+
+const result = await Bun.build({
+ entrypoints: [entry],
+ outdir: out,
+ target: "browser",
+ format: "esm",
+ sourcemap: "external",
+ minify: false,
+})
+
+if (!result.success) {
+ throw new Error(result.logs.map((log) => log.message).join("\n"))
+}
+
+const server = Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ fetch(req) {
+ const url = new URL(req.url)
+ const file = path.join(out, url.pathname === "/" ? "index.html" : url.pathname.slice(1))
+ return new Response(Bun.file(file))
+ },
+})
+
+const browser = await chromium.launch({ headless: true })
+
+try {
+ const tab = await browser.newPage()
+ tab.on("console", (msg) => console.log(msg.text()))
+ tab.on("pageerror", (err) => console.error(err.message))
+ await tab.goto(server.url.toString())
+ try {
+ await tab.waitForFunction(() => document.body.dataset.ready === "true", undefined, { timeout: 10_000 })
+ } catch {
+ const err = await tab.evaluate(() => document.body.dataset.error ?? "Missing screenshot data")
+ throw new Error(err)
+ }
+ const data = await tab.evaluate(() => window.__SHOT__)
+ if (!Array.isArray(data) || data.length === 0) {
+ throw new Error("Missing screenshot data")
+ }
+
+ for (const item of data) {
+ if (!item || typeof item.name !== "string" || typeof item.data !== "string") {
+ throw new Error("Invalid screenshot data")
+ }
+ if (!item.data.startsWith("data:image/png;base64,")) {
+ throw new Error("Missing screenshot data")
+ }
+ const png = path.join(out, item.name)
+ await writeFile(png, Buffer.from(item.data.replace("data:image/png;base64,", ""), "base64"))
+ console.log(png)
+ }
+} finally {
+ await browser.close()
+ server.stop(true)
+}
+
+declare global {
+ interface Window {
+ __SHOT__?: { name: string; data: string }[]
+ }
+}
diff --git a/packages/app/script/session-screenshot-fixture-real.json b/packages/app/script/session-screenshot-fixture-real.json
new file mode 100644
index 00000000000..4a5fd0d289a
--- /dev/null
+++ b/packages/app/script/session-screenshot-fixture-real.json
@@ -0,0 +1,97 @@
+{
+ "title": "Session screenshot renderer cleanup",
+ "dir": "/repo/opencode",
+ "sessionID": "ses_real",
+ "messages": [
+ {
+ "id": "user_1",
+ "sessionID": "ses_real",
+ "role": "user",
+ "time": { "created": 1742334600000 },
+ "agent": "build",
+ "model": { "providerID": "openai", "modelID": "gpt-5" }
+ },
+ {
+ "id": "assistant_1",
+ "sessionID": "ses_real",
+ "role": "assistant",
+ "time": { "created": 1742334630000, "completed": 1742334660000 },
+ "parentID": "user_1",
+ "modelID": "gpt-5",
+ "providerID": "openai",
+ "mode": "chat",
+ "agent": "build",
+ "path": { "cwd": "/repo/opencode", "root": "/repo/opencode" },
+ "cost": 0,
+ "tokens": { "input": 0, "output": 0, "reasoning": 0, "cache": { "read": 0, "write": 0 } }
+ },
+ {
+ "id": "user_2",
+ "sessionID": "ses_real",
+ "role": "user",
+ "time": { "created": 1742334720000 },
+ "agent": "build",
+ "model": { "providerID": "openai", "modelID": "gpt-5" }
+ },
+ {
+ "id": "assistant_2",
+ "sessionID": "ses_real",
+ "role": "assistant",
+ "time": { "created": 1742334750000, "completed": 1742334810000 },
+ "parentID": "user_2",
+ "modelID": "gpt-5",
+ "providerID": "openai",
+ "mode": "chat",
+ "agent": "build",
+ "path": { "cwd": "/repo/opencode", "root": "/repo/opencode" },
+ "cost": 0,
+ "tokens": { "input": 0, "output": 0, "reasoning": 0, "cache": { "read": 0, "write": 0 } }
+ }
+ ],
+ "parts": {
+ "user_1": [
+ {
+ "id": "p_user_1",
+ "sessionID": "ses_real",
+ "messageID": "user_1",
+ "type": "text",
+ "text": "make the exported session screenshot look closer to the actual desktop conversation",
+ "synthetic": false,
+ "ignored": false
+ }
+ ],
+ "assistant_1": [
+ {
+ "id": "p_assistant_1",
+ "sessionID": "ses_real",
+ "messageID": "assistant_1",
+ "type": "text",
+ "text": "I can flatten the design, tighten spacing, and use chat-like metadata so the export reads like a real transcript instead of a polished promo card.",
+ "synthetic": false,
+ "ignored": false
+ }
+ ],
+ "user_2": [
+ {
+ "id": "p_user_2",
+ "sessionID": "ses_real",
+ "messageID": "user_2",
+ "type": "text",
+ "text": "also add a CLI fixture so we can render and inspect the screenshot from the command line",
+ "synthetic": false,
+ "ignored": false
+ }
+ ],
+ "assistant_2": [
+ {
+ "id": "p_assistant_2",
+ "sessionID": "ses_real",
+ "messageID": "assistant_2",
+ "type": "text",
+ "text": "Done. The renderer now uses title, bubble placement, metadata rows, and slimmer spacing, and the CLI script can generate PNGs from saved fixture JSON for regression checks.",
+ "synthetic": false,
+ "ignored": false
+ }
+ ]
+ }
+}
diff --git a/packages/app/script/session-screenshot-fixture.json b/packages/app/script/session-screenshot-fixture.json
new file mode 100644
index 00000000000..69a4eb640cf
--- /dev/null
+++ b/packages/app/script/session-screenshot-fixture.json
@@ -0,0 +1,53 @@
+{
+ "title": "Strawberry RS count inquiry",
+ "dir": "/repo/strawberry",
+ "sessionID": "ses_demo",
+ "messages": [
+ {
+ "id": "user_1",
+ "sessionID": "ses_demo",
+ "role": "user",
+ "time": { "created": 1742331000000 },
+ "agent": "build",
+ "model": { "providerID": "openrouter", "modelID": "deepseek-v3.2" }
+ },
+ {
+ "id": "assistant_1",
+ "sessionID": "ses_demo",
+ "role": "assistant",
+ "time": { "created": 1742331030000, "completed": 1742331060000 },
+ "parentID": "user_1",
+ "modelID": "deepseek-v3.2",
+ "providerID": "openrouter",
+ "mode": "chat",
+ "agent": "build",
+ "path": { "cwd": "/repo/strawberry", "root": "/repo/strawberry" },
+ "cost": 0,
+ "tokens": { "input": 0, "output": 0, "reasoning": 0, "cache": { "read": 0, "write": 0 } }
+ }
+ ],
+ "parts": {
+ "user_1": [
+ {
+ "id": "p_user_1",
+ "sessionID": "ses_demo",
+ "messageID": "user_1",
+ "type": "text",
+ "text": "how many rs in strawberry",
+ "synthetic": false,
+ "ignored": false
+ }
+ ],
+ "assistant_1": [
+ {
+ "id": "p_assistant_1",
+ "sessionID": "ses_demo",
+ "messageID": "assistant_1",
+ "type": "text",
+ "text": "3",
+ "synthetic": false,
+ "ignored": false
+ }
+ ]
+ }
+}
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index 86f3321e464..78b5fab6c8d 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -45,6 +45,9 @@ export type Platform = {
/** Save file picker dialog (Tauri only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise
+ /** Default downloads path for a file (desktop only) */
+ getDownloadsPath?(name: string): Promise
+
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
@@ -86,6 +89,9 @@ export type Platform = {
/** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise
+
+ /** Write a local file (desktop only) */
+ writeFile?(path: string, data: Uint8Array): Promise
}
export type DisplayBackend = "auto" | "wayland"
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 7e95fd739df..0be52d5b27f 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -83,6 +83,8 @@ export const dict = {
"command.session.compact.description": "Summarize the session to reduce context size",
"command.session.fork": "Fork from message",
"command.session.fork.description": "Create a new session from a previous message",
+ "command.session.screenshot": "Export screenshot",
+ "command.session.screenshot.description": "Render a shareable image from this conversation",
"command.session.share": "Share session",
"command.session.share.description": "Share this session and copy the URL to clipboard",
"command.session.unshare": "Unshare session",
@@ -435,6 +437,10 @@ export const dict = {
"toast.session.share.success.description": "Share URL copied to clipboard!",
"toast.session.share.failed.title": "Failed to share session",
"toast.session.share.failed.description": "An error occurred while sharing the session",
+ "toast.session.screenshot.success.title": "Screenshot ready",
+ "toast.session.screenshot.success.description": "Screenshot download started",
+ "toast.session.screenshot.failed.title": "Failed to export screenshot",
+ "toast.session.screenshot.failed.description": "Couldn't render the conversation image",
"toast.session.unshare.success.title": "Session unshared",
"toast.session.unshare.success.description": "Session unshared successfully!",
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 7a3b72ae4e9..eb1c296e195 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -1,4 +1,4 @@
-import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
+import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
@@ -8,20 +8,26 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
+import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { TextField } from "@opencode-ai/ui/text-field"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
+import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
+import { createSessionScreenshotAction } from "@/pages/session/session-screenshot-action"
type MessageComment = {
path: string
@@ -160,7 +166,7 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
- startTransition(() => setState("count", count))
+ setState("count", count)
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
@@ -209,13 +215,15 @@ export function MessageTimeline(props: {
}) {
let touchGesture: number | undefined
- const params = useParams()
const navigate = useNavigate()
+ const params = useParams()
+ const globalSDK = useGlobalSDK()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
+ const platform = usePlatform()
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -235,6 +243,39 @@ export function MessageTimeline(props: {
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
+ const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
+ const [slot, setSlot] = createStore({
+ open: false,
+ show: false,
+ fade: false,
+ })
+
+ let f: number | undefined
+ const clear = () => {
+ if (f !== undefined) window.clearTimeout(f)
+ f = undefined
+ }
+
+ onCleanup(clear)
+ createEffect(
+ on(
+ working,
+ (on, prev) => {
+ clear()
+ if (on) {
+ setSlot({ open: true, show: true, fade: false })
+ return
+ }
+ if (prev) {
+ setSlot({ open: false, show: true, fade: true })
+ f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
+ return
+ }
+ setSlot({ open: false, show: false, fade: false })
+ },
+ { defer: true },
+ ),
+ )
const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID
if (parentID) {
@@ -260,6 +301,8 @@ export function MessageTimeline(props: {
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
+ const shareUrl = createMemo(() => info()?.share?.url)
+ const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 }
@@ -269,6 +312,16 @@ export function MessageTimeline(props: {
messages: () => props.renderedUserMessages,
config: stageCfg,
})
+ const screen = createSessionScreenshotAction({
+ sessionID,
+ title: () => info()?.title,
+ dir: () => sdk.directory,
+ messages: sessionMessages,
+ parts: (id) => sync.data.part[id] ?? [],
+ revert: () => info()?.revert?.messageID,
+ platform,
+ language,
+ })
const [title, setTitle] = createStore({
draft: "",
@@ -276,9 +329,55 @@ export function MessageTimeline(props: {
saving: false,
menuOpen: false,
pendingRename: false,
+ pendingShare: false,
})
let titleRef: HTMLInputElement | undefined
+ const [share, setShare] = createStore({
+ open: false,
+ dismiss: null as "escape" | "outside" | null,
+ })
+
+ let more: HTMLButtonElement | undefined
+
+ const [req, setReq] = createStore({ share: false, unshare: false })
+
+ const shareSession = () => {
+ const id = sessionID()
+ if (!id || req.share) return
+ if (!shareEnabled()) return
+ setReq("share", true)
+ globalSDK.client.session
+ .share({ sessionID: id, directory: sdk.directory })
+ .catch((err: unknown) => {
+ console.error("Failed to share session", err)
+ })
+ .finally(() => {
+ setReq("share", false)
+ })
+ }
+
+ const unshareSession = () => {
+ const id = sessionID()
+ if (!id || req.unshare) return
+ if (!shareEnabled()) return
+ setReq("unshare", true)
+ globalSDK.client.session
+ .unshare({ sessionID: id, directory: sdk.directory })
+ .catch((err: unknown) => {
+ console.error("Failed to unshare session", err)
+ })
+ .finally(() => {
+ setReq("unshare", false)
+ })
+ }
+
+ const viewShare = () => {
+ const url = shareUrl()
+ if (!url) return
+ platform.openLink(url)
+ }
+
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
@@ -291,7 +390,15 @@ export function MessageTimeline(props: {
createEffect(
on(
sessionKey,
- () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
+ () =>
+ setTitle({
+ draft: "",
+ editing: false,
+ saving: false,
+ menuOpen: false,
+ pendingRename: false,
+ pendingShare: false,
+ }),
{ defer: true },
),
)
@@ -573,45 +680,66 @@ export function MessageTimeline(props: {
aria-label={language.t("common.goBack")}
/>
-
-
- {titleValue()}
-
- }
+
+
-
{
- titleRef = el
- }}
- value={title.draft}
- disabled={title.saving}
- class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
- style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
- onInput={(event) => setTitle("draft", event.currentTarget.value)}
- onKeyDown={(event) => {
- event.stopPropagation()
- if (event.key === "Enter") {
- event.preventDefault()
- void saveTitleEditor()
- return
- }
- if (event.key === "Escape") {
- event.preventDefault()
- closeTitleEditor()
- }
- }}
- onBlur={closeTitleEditor}
- />
+
+
+
+
+
+
+
+
+ {titleValue()}
+
+ }
+ >
+ {
+ titleRef = el
+ }}
+ value={title.draft}
+ disabled={title.saving}
+ class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
+ style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
+ onInput={(event) => setTitle("draft", event.currentTarget.value)}
+ onKeyDown={(event) => {
+ event.stopPropagation()
+ if (event.key === "Enter") {
+ event.preventDefault()
+ void saveTitleEditor()
+ return
+ }
+ if (event.key === "Escape") {
+ event.preventDefault()
+ closeTitleEditor()
+ }
+ }}
+ onBlur={closeTitleEditor}
+ />
+
-
+
-
+
{(id) => (
@@ -619,23 +747,42 @@ export function MessageTimeline(props: {
gutter={4}
placement="bottom-end"
open={title.menuOpen}
- onOpenChange={(open) => setTitle("menuOpen", open)}
+ onOpenChange={(open) => {
+ setTitle("menuOpen", open)
+ if (open) return
+ }}
>
{
+ more = el
+ }}
/>
{
- if (!title.pendingRename) return
- event.preventDefault()
- setTitle("pendingRename", false)
- openTitleEditor()
+ if (title.pendingRename) {
+ event.preventDefault()
+ setTitle("pendingRename", false)
+ openTitleEditor()
+ return
+ }
+ if (title.pendingShare) {
+ event.preventDefault()
+ requestAnimationFrame(() => {
+ setShare({ open: true, dismiss: null })
+ setTitle("pendingShare", false)
+ })
+ }
}}
>
{language.t("common.rename")}
- void archiveSession(id)}>
+
+ {
+ setTitle({ pendingShare: true, menuOpen: false })
+ }}
+ >
+
+ {language.t("session.share.action.share")}
+
+
+
+ void archiveSession(id())}>
{language.t("common.archive")}
+
+ void screen.shot()}>
+
+ {language.t("command.session.screenshot")}
+
+
+
dialog.show(() => )}
+ onSelect={() => dialog.show(() => )}
>
{language.t("common.delete")}
+
+ more}
+ placement="bottom-end"
+ gutter={4}
+ modal={false}
+ onOpenChange={(open) => {
+ if (open) setShare("dismiss", null)
+ setShare("open", open)
+ }}
+ >
+
+ {
+ setShare({ dismiss: "escape", open: false })
+ event.preventDefault()
+ event.stopPropagation()
+ }}
+ onPointerDownOutside={() => {
+ setShare({ dismiss: "outside", open: false })
+ }}
+ onFocusOutside={() => {
+ setShare({ dismiss: "outside", open: false })
+ }}
+ onCloseAutoFocus={(event) => {
+ if (share.dismiss === "outside") event.preventDefault()
+ setShare("dismiss", null)
+ }}
+ >
+
+
+
+ {language.t("session.share.popover.title")}
+
+
+ {shareUrl()
+ ? language.t("session.share.popover.description.shared")
+ : language.t("session.share.popover.description.unshared")}
+
+
+
+
+ {req.share
+ ? language.t("session.share.action.publishing")
+ : language.t("session.share.action.publish")}
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
)}
@@ -693,12 +956,6 @@ export function MessageTimeline(props: {
{(messageID) => {
const active = createMemo(() => activeMessageID() === messageID)
- const queued = createMemo(() => {
- if (active()) return false
- const activeID = activeMessageID()
- if (activeID) return messageID > activeID
- return false
- })
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
})
@@ -715,6 +972,7 @@ export function MessageTimeline(props: {
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
+ style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
>
0}>
@@ -724,27 +982,31 @@ export function MessageTimeline(props: {
{(commentAccessor: () => MessageComment) => {
const comment = createMemo(() => commentAccessor())
return (
-
-
-
- {getFilename(comment().path)}
-
- {(selection) => (
-
- {selection().startLine === selection().endLine
- ? `:${selection().startLine}`
- : `:${selection().startLine}-${selection().endLine}`}
-
- )}
-
-
-
- {comment().comment}
-
-
+
+ {(c) => (
+
+
+
+ {getFilename(c().path)}
+
+ {(selection) => (
+
+ {selection().startLine === selection().endLine
+ ? `:${selection().startLine}`
+ : `:${selection().startLine}-${selection().endLine}`}
+
+ )}
+
+
+
+ {c().comment}
+
+
+ )}
+
)
}}
@@ -756,7 +1018,6 @@ export function MessageTimeline(props: {
sessionID={sessionID() ?? ""}
messageID={messageID}
active={active()}
- queued={queued()}
status={active() ? sessionStatus() : undefined}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
diff --git a/packages/app/src/pages/session/session-command-screenshot.test.ts b/packages/app/src/pages/session/session-command-screenshot.test.ts
new file mode 100644
index 00000000000..0db938d73e4
--- /dev/null
+++ b/packages/app/src/pages/session/session-command-screenshot.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, mock, test } from "bun:test"
+import { createSessionScreenshotCommand } from "./session-command-screenshot"
+
+describe("createSessionScreenshotCommand", () => {
+ test("builds screenshot command metadata", () => {
+ const option = createSessionScreenshotCommand({
+ command: (option) => ({ ...option, category: "session" }),
+ language: { t: (key) => key },
+ ready: () => true,
+ shot: async () => {},
+ })
+
+ expect(option.id).toBe("session.screenshot")
+ expect(option.title).toBe("command.session.screenshot")
+ expect(option.description).toBe("command.session.screenshot.description")
+ expect(option.slash).toBe("screenshot")
+ expect(option.disabled).toBe(false)
+ expect(option.category).toBe("session")
+ })
+
+ test("disables command when screenshot is unavailable", () => {
+ const option = createSessionScreenshotCommand({
+ command: (option) => ({ ...option, category: "session" }),
+ language: { t: (key) => key },
+ ready: () => false,
+ shot: async () => {},
+ })
+
+ expect(option.disabled).toBe(true)
+ })
+
+ test("runs screenshot action on select", async () => {
+ const shot = mock(async () => {})
+ const option = createSessionScreenshotCommand({
+ command: (option) => ({ ...option, category: "session" }),
+ language: { t: (key) => key },
+ ready: () => true,
+ shot,
+ })
+
+ option.onSelect?.()
+ await Promise.resolve()
+ expect(shot).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/app/src/pages/session/session-command-screenshot.ts b/packages/app/src/pages/session/session-command-screenshot.ts
new file mode 100644
index 00000000000..1cee0a8d649
--- /dev/null
+++ b/packages/app/src/pages/session/session-command-screenshot.ts
@@ -0,0 +1,23 @@
+import type { Accessor } from "solid-js"
+import type { CommandOption } from "@/context/command"
+
+type Lang = {
+ t: (key: string) => string
+}
+
+type Input = {
+ command: (option: Omit
) => CommandOption
+ language: Lang
+ ready: Accessor
+ shot: () => Promise
+}
+
+export const createSessionScreenshotCommand = (input: Input) =>
+ input.command({
+ id: "session.screenshot",
+ title: input.language.t("command.session.screenshot"),
+ description: input.language.t("command.session.screenshot.description"),
+ slash: "screenshot",
+ disabled: !input.ready(),
+ onSelect: () => void input.shot(),
+ })
diff --git a/packages/app/src/pages/session/session-screenshot-action.ts b/packages/app/src/pages/session/session-screenshot-action.ts
new file mode 100644
index 00000000000..88c086a1b69
--- /dev/null
+++ b/packages/app/src/pages/session/session-screenshot-action.ts
@@ -0,0 +1,75 @@
+import { createMemo, type Accessor } from "solid-js"
+import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2"
+import { showToast } from "@opencode-ai/ui/toast"
+import type { Platform } from "@/context/platform"
+import { createSessionScreenshot, saveScreenshot, screenshotName } from "@/pages/session/session-screenshot"
+
+type Lang = {
+ t: (key: string) => string
+}
+
+type Input = {
+ sessionID: Accessor
+ title: Accessor
+ dir: Accessor
+ messages: Accessor
+ parts: (id: string) => Part[]
+ revert: Accessor
+ platform: Platform
+ language: Lang
+}
+
+export function createSessionScreenshotAction(input: Input) {
+ const users = createMemo(() => input.messages().filter((msg): msg is UserMessage => msg.role === "user"))
+ const visible = createMemo(() => {
+ const revert = input.revert()
+ if (!revert) return users()
+ return users().filter((msg) => msg.id < revert)
+ })
+ const ready = createMemo(() => input.platform.platform === "desktop" && !!input.sessionID() && visible().length > 0)
+
+ const shot = async () => {
+ const id = input.sessionID()
+ if (!id || !ready()) return
+
+ const blob = await createSessionScreenshot({
+ sessionID: id,
+ title: input.title(),
+ dir: input.dir(),
+ messages: input.messages(),
+ parts: input.parts,
+ revert: input.revert(),
+ }).catch(() => undefined)
+
+ if (!blob) {
+ showToast({
+ title: input.language.t("toast.session.screenshot.failed.title"),
+ description: input.language.t("toast.session.screenshot.failed.description"),
+ variant: "error",
+ })
+ return
+ }
+
+ const path = await saveScreenshot(blob, screenshotName(input.title()), input.platform).catch(() => undefined)
+ if (path === undefined) return
+
+ showToast({
+ title: input.language.t("toast.session.screenshot.success.title"),
+ description: path ?? input.language.t("toast.session.screenshot.success.description"),
+ actions:
+ path && input.platform.openPath
+ ? [
+ {
+ label: input.language.t("common.open"),
+ onClick: () => {
+ void input.platform.openPath!(path)
+ },
+ },
+ ]
+ : undefined,
+ variant: "success",
+ })
+ }
+
+ return { ready, shot }
+}
diff --git a/packages/app/src/pages/session/session-screenshot.test.ts b/packages/app/src/pages/session/session-screenshot.test.ts
new file mode 100644
index 00000000000..ecbe71e4e5e
--- /dev/null
+++ b/packages/app/src/pages/session/session-screenshot.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, test } from "bun:test"
+import type { Part } from "@opencode-ai/sdk/v2"
+import { screenshotName, screenshotText } from "./session-screenshot"
+
+describe("screenshotText", () => {
+ test("keeps prompt text and inline references", () => {
+ expect(
+ screenshotText([
+ {
+ type: "text",
+ text: "Review @src/app.ts with @build and this image",
+ synthetic: false,
+ ignored: false,
+ } as unknown as Part,
+ {
+ type: "file",
+ id: "f_1",
+ mime: "text/plain",
+ filename: "app.ts",
+ url: "file:///repo/src/app.ts",
+ source: {
+ type: "text",
+ value: "@src/app.ts",
+ start: 7,
+ end: 18,
+ path: "/repo/src/app.ts",
+ },
+ } as unknown as Part,
+ {
+ type: "agent",
+ id: "a_1",
+ name: "build",
+ source: {
+ value: "@build",
+ start: 24,
+ end: 30,
+ },
+ } as unknown as Part,
+ {
+ type: "file",
+ id: "f_2",
+ mime: "image/png",
+ filename: "mock.png",
+ url: "data:image/png;base64,abc",
+ } as unknown as Part,
+ ]),
+ ).toBe("Review @src/app.ts with @build and this image[image: mock.png]")
+ })
+})
+
+describe("screenshotName", () => {
+ test("builds a clean png filename", () => {
+ expect(screenshotName("Ship launch: v1", new Date("2026-03-06T12:00:00.000Z"))).toBe(
+ "ship-launch-v1-screenshot-2026-03-06.png",
+ )
+ })
+})
diff --git a/packages/app/src/pages/session/session-screenshot.ts b/packages/app/src/pages/session/session-screenshot.ts
new file mode 100644
index 00000000000..92f5b540243
--- /dev/null
+++ b/packages/app/src/pages/session/session-screenshot.ts
@@ -0,0 +1,258 @@
+import type { AssistantMessage, Message, Part, TextPart } from "@opencode-ai/sdk/v2"
+import { getFilename } from "@opencode-ai/util/path"
+import type { Platform } from "@/context/platform"
+import { extractPromptFromParts } from "@/utils/prompt"
+import logo from "../../../../console/app/src/asset/logo.svg"
+
+const W = 788
+const SIDE = 18
+const TOP = 18
+const GAP = 18
+const USER_W = 288
+const ASSIST_W = 520
+const USER_LINE = 20
+const ASSIST_LINE = 22
+const USER_FONT = '400 16px "SF Pro Text", "Inter", "Segoe UI", sans-serif'
+const ASSIST_FONT = '400 18px "SF Pro Text", "Inter", "Segoe UI", sans-serif'
+const TITLE_FONT = '400 15px "SF Pro Text", "Inter", "Segoe UI", sans-serif'
+const BOTTOM = 28
+const LOGO_W = 120
+
+type Turn = {
+ role: "user" | "assistant"
+ text: string
+}
+
+type ShotInput = {
+ sessionID: string
+ title?: string
+ messages: Message[]
+ parts: (id: string) => Part[]
+ revert?: string
+ dir?: string
+}
+
+type Block = Turn & {
+ lines: string[]
+ h: number
+}
+
+const bubble = (ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) => {
+ ctx.beginPath()
+ ctx.moveTo(x + r, y)
+ ctx.arcTo(x + w, y, x + w, y + h, r)
+ ctx.arcTo(x + w, y + h, x, y + h, r)
+ ctx.arcTo(x, y + h, x, y, r)
+ ctx.arcTo(x, y, x + w, y, r)
+ ctx.closePath()
+}
+
+const clean = (text: string) => text.replace(/\s+/g, " ").trim()
+
+const shrink = (text: string, max: number) => {
+ if (text.length <= max) return text
+ return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}...`
+}
+
+const load = async (src: string) => {
+ const img = new Image()
+ img.decoding = "sync"
+ img.src = src
+ await img.decode()
+ return img
+}
+
+const wrap = (ctx: CanvasRenderingContext2D, text: string, max: number, rows: number) => {
+ const list: string[] = []
+ const words = clean(text).split(" ").filter(Boolean)
+ let line = ""
+
+ for (const word of words) {
+ const next = line ? `${line} ${word}` : word
+ if (ctx.measureText(next).width <= max) {
+ line = next
+ continue
+ }
+
+ if (line) list.push(line)
+ line = word
+ if (list.length === rows) return list
+ }
+
+ if (line) list.push(line)
+ if (list.length <= rows) return list
+ return [...list.slice(0, rows - 1), shrink(list[rows - 1], Math.max(12, Math.floor(max / 14)))]
+}
+
+const assistantText = (parts: Part[], msg: AssistantMessage) => {
+ const text = clean(
+ parts
+ .filter((part): part is TextPart => part.type === "text" && !part.synthetic && !part.ignored)
+ .map((part) => part.text)
+ .join(" "),
+ )
+ if (text) return text
+ const err = msg.error?.data
+ if (!err || typeof err !== "object" || !("message" in err) || typeof err.message !== "string") return ""
+ return clean(err.message)
+}
+
+const blocks = (ctx: CanvasRenderingContext2D, turns: Turn[]) =>
+ turns.map((turn) => {
+ const user = turn.role === "user"
+ ctx.font = user ? USER_FONT : ASSIST_FONT
+ const lines = wrap(ctx, turn.text, (user ? USER_W : ASSIST_W) - (user ? 40 : 0), user ? 4 : 8)
+ const text_h = lines.length * (user ? USER_LINE : ASSIST_LINE)
+ const h = user ? Math.max(40, text_h + 24) : text_h
+ return { ...turn, lines, h }
+ })
+
+export function screenshotText(parts: Part[], dir?: string) {
+ return clean(
+ extractPromptFromParts(parts, { directory: dir, attachmentName: "image" })
+ .map((part) => {
+ if (part.type === "text") return part.content
+ if (part.type === "file") return part.content
+ if (part.type === "agent") return part.content
+ return `[image: ${part.filename}]`
+ })
+ .join(""),
+ )
+}
+
+export function screenshotTurns(input: ShotInput) {
+ return input.messages.flatMap((msg) => {
+ if (msg.sessionID !== input.sessionID) return []
+ if (msg.role === "user") {
+ if (input.revert && msg.id >= input.revert) return []
+ const text = screenshotText(input.parts(msg.id), input.dir)
+ if (!text) return []
+ return [{ role: "user", text }]
+ }
+
+ if (input.revert && msg.parentID >= input.revert) return []
+ const text = assistantText(input.parts(msg.id), msg)
+ if (!text) return []
+ return [{ role: "assistant", text }]
+ })
+}
+
+export function screenshotName(title?: string, now = new Date()) {
+ const date = now.toISOString().slice(0, 10)
+ const base = (title ?? "session")
+ .normalize("NFKD")
+ .replace(/[^\w\s-]/g, "")
+ .trim()
+ .replace(/\s+/g, "-")
+ .toLowerCase()
+ .slice(0, 48)
+ return `${base || "session"}-screenshot-${date}.png`
+}
+
+export function downloadScreenshot(blob: Blob, name: string) {
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = name
+ a.rel = "noopener"
+ a.style.display = "none"
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
+
+export async function saveScreenshot(blob: Blob, name: string, platform?: Platform) {
+ if (!platform?.getDownloadsPath || !platform.writeFile) {
+ downloadScreenshot(blob, name)
+ return null
+ }
+
+ const path = await platform.getDownloadsPath(name).catch(() => null)
+ if (!path) return null
+ await platform.writeFile(path, new Uint8Array(await blob.arrayBuffer()))
+ return path
+}
+
+export async function createSessionScreenshot(input: ShotInput) {
+ return renderSessionScreenshot({
+ title: input.title?.trim() || getFilename(input.dir ?? "") || "Session",
+ turns: screenshotTurns(input),
+ })
+}
+
+export async function renderSessionScreenshot(input: { title?: string; turns: Turn[] }) {
+ const turns = input.turns.filter((turn) => !!turn.text)
+ if (!turns.length) throw new Error("Missing message")
+
+ if (typeof document !== "undefined" && "fonts" in document) {
+ await document.fonts.ready.catch(() => undefined)
+ }
+
+ const scale = 2
+ const probe = document.createElement("canvas")
+ const probe_ctx = probe.getContext("2d")
+ if (!probe_ctx) throw new Error("Missing canvas")
+ const list = blocks(probe_ctx, turns)
+ const header = input.title ? 34 : 0
+ const H = Math.max(
+ 240,
+ TOP + header + list.reduce((sum, item) => sum + item.h, 0) + GAP * (list.length - 1) + BOTTOM + 34,
+ )
+
+ const canvas = document.createElement("canvas")
+ canvas.width = W * scale
+ canvas.height = H * scale
+ canvas.style.width = `${W}px`
+ canvas.style.height = `${H}px`
+
+ const ctx = canvas.getContext("2d")
+ if (!ctx) throw new Error("Missing canvas")
+ ctx.scale(scale, scale)
+ ctx.fillStyle = "#ffffff"
+ ctx.fillRect(0, 0, W, H)
+
+ let y = TOP
+
+ if (input.title) {
+ ctx.fillStyle = "#191919"
+ ctx.font = TITLE_FONT
+ ctx.fillText(input.title, SIDE, y + 16)
+ y += header
+ }
+
+ list.forEach((item) => {
+ if (item.role === "user") {
+ ctx.font = USER_FONT
+ const w = Math.min(USER_W, Math.max(...item.lines.map((line) => ctx.measureText(line).width), 76) + 40)
+ const x = W - SIDE - w
+ bubble(ctx, x, y, w, item.h, 8)
+ ctx.fillStyle = "#f7f5f2"
+ ctx.fill()
+ ctx.strokeStyle = "rgba(208, 203, 198, 0.95)"
+ ctx.lineWidth = 1
+ ctx.stroke()
+ ctx.fillStyle = "#2c2c2c"
+ item.lines.forEach((line, i) => {
+ ctx.fillText(line, x + 20, y + 26 + i * USER_LINE)
+ })
+ y += item.h + GAP
+ return
+ }
+
+ ctx.fillStyle = "#222222"
+ ctx.font = ASSIST_FONT
+ item.lines.forEach((line, i) => {
+ ctx.fillText(line, SIDE, y + 18 + i * ASSIST_LINE)
+ })
+ y += item.h + GAP
+ })
+
+ const mark = await load(logo)
+ const logoH = LOGO_W * (mark.height / mark.width)
+ ctx.drawImage(mark, Math.round((W - LOGO_W) / 2), H - logoH - 14, LOGO_W, logoH)
+
+ const blob = await new Promise((resolve) => canvas.toBlob((item) => resolve(item), "image/png"))
+ if (!blob) throw new Error("Missing blob")
+ return blob
+}
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index 461351878b6..f6572b39749 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -8,6 +8,7 @@ import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { usePrompt } from "@/context/prompt"
+import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
@@ -20,6 +21,8 @@ import { findLast } from "@opencode-ai/util/array"
import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
+import { createSessionScreenshotAction } from "@/pages/session/session-screenshot-action"
+import { createSessionScreenshotCommand } from "@/pages/session/session-command-screenshot"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -42,6 +45,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
+ const platform = usePlatform()
const sdk = useSDK()
const sync = useSync()
const terminal = useTerminal()
@@ -57,6 +61,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const idle = { type: "idle" as const }
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+ const screen = createSessionScreenshotAction({
+ sessionID: () => params.id,
+ title: () => info()?.title,
+ dir: () => sdk.directory,
+ messages,
+ parts: (id) => sync.data.part[id] ?? [],
+ revert: () => info()?.revert?.messageID,
+ platform,
+ language,
+ })
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
const visibleUserMessages = createMemo(() => {
const revert = info()?.revert?.messageID
@@ -368,6 +382,12 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => ),
}),
+ createSessionScreenshotCommand({
+ command: sessionCommand,
+ language,
+ ready: screen.ready,
+ shot: screen.shot,
+ }),
])
const shareCommands = createMemo(() => {
diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts
index bbb5379bb7a..9d48c8e72d3 100644
--- a/packages/desktop-electron/src/main/ipc.ts
+++ b/packages/desktop-electron/src/main/ipc.ts
@@ -1,4 +1,6 @@
import { execFile } from "node:child_process"
+import { writeFile } from "node:fs/promises"
+import { join } from "node:path"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
@@ -114,6 +116,10 @@ export function registerIpcHandlers(deps: Deps) {
},
)
+ ipcMain.handle("get-downloads-path", (_event: IpcMainInvokeEvent, name: string) =>
+ join(app.getPath("downloads"), name),
+ )
+
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
void shell.openExternal(url)
})
@@ -127,6 +133,10 @@ export function registerIpcHandlers(deps: Deps) {
})
})
+ ipcMain.handle("write-file", async (_event: IpcMainInvokeEvent, path: string, data: Uint8Array) => {
+ await writeFile(path, Buffer.from(data))
+ })
+
ipcMain.handle("read-clipboard-image", () => {
const image = clipboard.readImage()
if (image.isEmpty()) return null
diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts
index a6520ab4242..521ac591f50 100644
--- a/packages/desktop-electron/src/preload/index.ts
+++ b/packages/desktop-electron/src/preload/index.ts
@@ -47,8 +47,10 @@ const api: ElectronAPI = {
openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts),
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
+ getDownloadsPath: (name) => ipcRenderer.invoke("get-downloads-path", name),
openLink: (url) => ipcRenderer.send("open-link", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
+ writeFile: (path, data) => ipcRenderer.invoke("write-file", path, data),
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts
index af5410f5f55..584b017bb0d 100644
--- a/packages/desktop-electron/src/preload/types.ts
+++ b/packages/desktop-electron/src/preload/types.ts
@@ -47,8 +47,10 @@ export type ElectronAPI = {
defaultPath?: string
}) => Promise
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise
+ getDownloadsPath: (name: string) => Promise
openLink: (url: string) => void
openPath: (path: string, app?: string) => Promise
+ writeFile: (path: string, data: Uint8Array) => Promise
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
showNotification: (title: string, body?: string) => void
getWindowFocused: () => Promise
diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx
index b5193d626bd..c6119ec149c 100644
--- a/packages/desktop-electron/src/renderer/index.tsx
+++ b/packages/desktop-electron/src/renderer/index.tsx
@@ -124,6 +124,10 @@ const createPlatform = (): Platform => {
return handleWslPicker(result)
},
+ async getDownloadsPath(name) {
+ return window.api.getDownloadsPath(name)
+ },
+
openLink(url: string) {
window.api.openLink(url)
},
@@ -142,6 +146,10 @@ const createPlatform = (): Platform => {
return window.api.openPath(path, app)
},
+ async writeFile(path, data) {
+ await window.api.writeFile(path, data)
+ },
+
back() {
window.history.back()
},
diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs
index 137692cdf73..3ff7f3c9090 100644
--- a/packages/desktop/src-tauri/src/lib.rs
+++ b/packages/desktop/src-tauri/src/lib.rs
@@ -208,6 +208,22 @@ fn open_path(_app: AppHandle, path: String, app_name: Option) -> Result<
.map_err(|e| format!("Failed to open path: {e}"))
}
+#[tauri::command]
+#[specta::specta]
+fn write_file(path: String, data: Vec) -> Result<(), String> {
+ std::fs::write(path, data).map_err(|e| format!("Failed to write file: {e}"))
+}
+
+#[tauri::command]
+#[specta::specta]
+fn get_downloads_path(app: AppHandle, name: String) -> Result {
+ let dir = app
+ .path()
+ .download_dir()
+ .map_err(|e| format!("Failed to resolve downloads path: {e}"))?;
+ Ok(dir.join(name).to_string_lossy().into_owned())
+}
+
#[cfg(target_os = "macos")]
fn check_macos_app(app_name: &str) -> bool {
// Check common installation locations
@@ -403,7 +419,9 @@ fn make_specta_builder() -> tauri_specta::Builder {
check_app_exists,
wsl_path,
resolve_app_path,
- open_path
+ open_path,
+ write_file,
+ get_downloads_path
])
.events(tauri_specta::collect_events![
LoadingWindowComplete,
diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts
index 80548173e92..96a56289e2d 100644
--- a/packages/desktop/src/bindings.ts
+++ b/packages/desktop/src/bindings.ts
@@ -19,6 +19,8 @@ export const commands = {
wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE("wsl_path", { path, mode }),
resolveAppPath: (appName: string) => __TAURI_INVOKE("resolve_app_path", { appName }),
openPath: (path: string, appName: string | null) => __TAURI_INVOKE("open_path", { path, appName }),
+ writeFile: (path: string, data: number[]) => __TAURI_INVOKE("write_file", { path, data }),
+ getDownloadsPath: (name: string) => __TAURI_INVOKE("get_downloads_path", { name }),
};
/** Events */
diff --git a/packages/desktop/src/i18n/en.ts b/packages/desktop/src/i18n/en.ts
index f93fe58f77a..fb14a3c6f9f 100644
--- a/packages/desktop/src/i18n/en.ts
+++ b/packages/desktop/src/i18n/en.ts
@@ -10,6 +10,7 @@ export const dict = {
"desktop.menu.help": "Help",
"desktop.menu.file.newSession": "New Session",
"desktop.menu.file.openProject": "Open Project...",
+ "desktop.menu.file.exportScreenshot": "Export Screenshot...",
"desktop.menu.view.toggleSidebar": "Toggle Sidebar",
"desktop.menu.view.toggleTerminal": "Toggle Terminal",
"desktop.menu.view.toggleFileTree": "Toggle File Tree",
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index 9afabe918b1..39ea7c022f9 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -111,6 +111,10 @@ const createPlatform = (): Platform => {
return handleWslPicker(result)
},
+ async getDownloadsPath(name) {
+ return commands.getDownloadsPath(name)
+ },
+
openLink(url: string) {
void shellOpen(url).catch(() => undefined)
},
@@ -118,6 +122,10 @@ const createPlatform = (): Platform => {
await commands.openPath(path, app ?? null)
},
+ async writeFile(path, data) {
+ await commands.writeFile(path, [...data])
+ },
+
back() {
window.history.back()
},
diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts
index de6a1d6a76c..3e95a798b3d 100644
--- a/packages/desktop/src/menu.ts
+++ b/packages/desktop/src/menu.ts
@@ -74,6 +74,11 @@ export async function createMenu(trigger: (id: string) => void) {
accelerator: "Cmd+O",
action: () => trigger("project.open"),
}),
+ await MenuItem.new({
+ text: t("desktop.menu.file.exportScreenshot"),
+ accelerator: "Shift+Cmd+E",
+ action: () => trigger("session.screenshot"),
+ }),
await PredefinedMenuItem.new({
item: "Separator",
}),