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()} - - } +
+ + + + {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", }),