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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
src/assets/theme.css
e2e/test-results
e2e/playwright-report
.tmp/
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
137 changes: 137 additions & 0 deletions packages/app/script/session-screenshot-cli.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html>
<html>
<body>
<script type="module" src="./entry.js"></script>
</body>
</html>
`

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 }[]
}
}
97 changes: 97 additions & 0 deletions packages/app/script/session-screenshot-fixture-real.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
}
53 changes: 53 additions & 0 deletions packages/app/script/session-screenshot-fixture.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
}
6 changes: 6 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export type Platform = {
/** Save file picker dialog (Tauri only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>

/** Default downloads path for a file (desktop only) */
getDownloadsPath?(name: string): Promise<string | null>

/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage

Expand Down Expand Up @@ -86,6 +89,9 @@ export type Platform = {

/** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null>

/** Write a local file (desktop only) */
writeFile?(path: string, data: Uint8Array): Promise<void>
}

export type DisplayBackend = "auto" | "wayland"
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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!",
Expand Down
Loading
Loading