diff --git a/packages/app/e2e/performance/composer-history/README.md b/packages/app/e2e/performance/composer-history/README.md
new file mode 100644
index 00000000000..e33e15b201b
--- /dev/null
+++ b/packages/app/e2e/performance/composer-history/README.md
@@ -0,0 +1,37 @@
+# Composer History Hydration
+
+Manual benchmark for an empty destination composer. Runs the production
+`ComposerEditor`, `createComposerEditor`, `createComposerHistory`, persistence
+codec, and browser IndexedDB draft store. It does not run the surrounding app
+shell or native desktop IPC.
+
+Workload: 100 normal prompts with realistic review instructions/code and 100
+shell commands. Separate cases have no images, 50 unique screenshots, or 50
+references to 5 screenshots. The fixture generates valid 1440 x 900 PNG code
+screenshots before timing and reports their exact byte sizes. Each isolated
+browser context measures a cold URL-cache mount followed by a warm remount.
+The database was just seeded; this does not simulate a cold disk cache.
+
+`historyReadyMs` measures the mount action until both production history stores
+are populated. This is history availability, not time to first editable input
+(input can be usable before history finishes). The benchmark then verifies
+ArrowUp recall and a decoded screenshot in the real editor. `recallObservedMs`
+includes Playwright action/assertion overhead and is reported separately.
+`mountRecallObservedMs` includes the mount, readiness checks, keyboard action,
+and correct text/image completion; it also includes Playwright overhead.
+IndexedDB reads and blob sizes are mechanism metrics, not desktop IPC bytes or
+process memory. No timing threshold is enforced.
+
+From `packages/app`, set `OPENCODE_HISTORY_BUILD` and
+`OPENCODE_HISTORY_OUTPUT` to artifact directories outside Git, then run:
+
+```sh
+bun x vite build --config e2e/performance/composer-history/vite.config.ts
+bun x playwright test --config e2e/performance/composer-history/playwright.config.ts --repeat-each=20
+```
+
+The preview server owns port 4783 and is stopped by Playwright. Preserve each
+build and its revision/hash for comparisons. `BENCHMARK` JSON lines contain all
+raw samples. Optional Chrome traces use the existing
+`OPENCODE_PERFORMANCE_TRACE_DIR` setting; keep trace runs separate from clean
+timing. Screenshots are captured after timing on the first repeat only.
diff --git a/packages/app/e2e/performance/composer-history/composer-history.bench.ts b/packages/app/e2e/performance/composer-history/composer-history.bench.ts
new file mode 100644
index 00000000000..11f13e97203
--- /dev/null
+++ b/packages/app/e2e/performance/composer-history/composer-history.bench.ts
@@ -0,0 +1,48 @@
+import { benchmark, expect } from "../benchmark"
+
+benchmark.use({ traceScope: "page" })
+for (const shape of ["text", "unique", "repeated"]) {
+ benchmark(`composer global history: ${shape}, cold and warm mounts`, async ({ page, report }, testInfo) => {
+ const errors: string[] = []
+ page.on("pageerror", (error) => errors.push(error.message))
+ await page.goto(`/?shape=${shape}`)
+ const button = page.getByRole("button", { name: "Mount empty composer", exact: true })
+ const input = page.getByRole("textbox", { name: "Prompt", exact: true })
+ const samples = []
+ for (const cache of ["cold", "warm"]) {
+ await expect(button).toBeEnabled()
+ const mountStarted = performance.now()
+ await button.click()
+ await expect(page.getByTestId("history-ready")).toHaveText("ready")
+ await expect(input).toBeEditable()
+ await expect(input).toBeEmpty()
+ const result = JSON.parse((await page.getByTestId("history-result").textContent())!)
+ expect(result.documents).toBe(2)
+ expect(result.historyReadyMs).toBeGreaterThan(0)
+ const start = performance.now()
+ await input.press("ArrowUp")
+ await expect(input).toContainText("Review the retry policy in src/network/request-0.ts.")
+ const images = page.getByRole("img", { name: "request-0.png", exact: true })
+ await expect(images).toHaveCount(shape === "text" ? 0 : 1)
+ if (shape !== "text")
+ await expect
+ .poll(() => images.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth === 1440))
+ .toBe(true)
+ samples.push({
+ cache,
+ ...result,
+ recallObservedMs: performance.now() - start,
+ mountRecallObservedMs: performance.now() - mountStarted,
+ })
+ }
+ expect(errors).toEqual([])
+ report(
+ { samples },
+ {
+ browser: page.context().browser()!.version(),
+ scope: "production composer editor/history, browser IndexedDB; no native IPC",
+ },
+ )
+ if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath(`${shape}.png`) })
+ })
+}
diff --git a/packages/app/e2e/performance/composer-history/fixture.tsx b/packages/app/e2e/performance/composer-history/fixture.tsx
new file mode 100644
index 00000000000..5e24e2b44b7
--- /dev/null
+++ b/packages/app/e2e/performance/composer-history/fixture.tsx
@@ -0,0 +1,174 @@
+///
+import { createEffect, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { render } from "solid-js/web"
+import { PlatformProvider, type Platform } from "@/runtime/platform/platform"
+import { createBrowserDraftStore } from "@/runtime/persistence/drafts"
+import { createComposerHistory } from "@/composer/history/store"
+import { ComposerEditor } from "@/composer/editor/editor"
+import { createComposerEditor } from "@/composer/editor/interaction"
+import type { ComposerPersistedState } from "@/composer/types"
+import "@/index.css"
+
+const shape = new URLSearchParams(location.search).get("shape") ?? "text"
+const normal = Array.from({ length: 100 }, (_, index) => {
+ const content =
+ `Review the retry policy in src/network/request-${index}.ts. Preserve cancellation and the existing error messages.\n\n` +
+ `The request should stop after three attempts. Add coverage for a 429 response, a connection reset, and a successful retry. Verify that only idempotent requests are retried.\n\n` +
+ `Report ${index}:\n\`\`\`ts\nexport async function request(input: Request) {\n const response = await fetch(input)\n if (!response.ok) throw new Error(response.statusText)\n return response.json()\n}\n\`\`\``
+ return {
+ prompt: [
+ { type: "text", content, start: 0, end: content.length },
+ ...(shape !== "text" && index % 2 === 0
+ ? [
+ {
+ type: "image",
+ id: `attachment-${index}`,
+ filename: `request-${index}.png`,
+ mime: "image/png",
+ blob: { id: `screenshot-${shape === "repeated" ? index % 10 : index}` },
+ },
+ ]
+ : []),
+ ],
+ comments: [],
+ }
+})
+const shell = Array.from({ length: 100 }, (_, index) => {
+ const content = `bun test src/network/request-${index}.test.ts --timeout 30000`
+ return { prompt: [{ type: "text", content, start: 0, end: content.length }], comments: [] }
+})
+
+// Seed only this Playwright context, before opening the production draft store.
+const request = indexedDB.open("opencode-drafts", 1)
+request.onupgradeneeded = () => {
+ request.result.createObjectStore("documents")
+ request.result.createObjectStore("blobs")
+}
+const db = await new Promise((resolve, reject) => {
+ request.onsuccess = () => resolve(request.result)
+ request.onerror = () => reject(request.error)
+})
+const ids = [...new Set(normal.flatMap((entry) => entry.prompt.flatMap((part) => (part.blob ? [part.blob.id] : []))))]
+const screenshots: { id: string; blob: Blob }[] = []
+for (const id of ids) {
+ const canvas = document.createElement("canvas")
+ canvas.width = 1440
+ canvas.height = 900
+ const context = canvas.getContext("2d")!
+ context.fillStyle = "#15191f"
+ context.fillRect(0, 0, canvas.width, canvas.height)
+ context.font = "16px monospace"
+ context.fillStyle = "#b8c8d8"
+ context.fillText(`request.ts - ${id}`, 30, 35)
+ for (let line = 0; line < 38; line++) {
+ context.fillStyle = line % 3 ? "#a8c7ba" : "#d4a882"
+ context.fillText(
+ `${String(line + 1).padStart(3)} const response${line} = await fetch('/api/request/${id}/${line}', { signal, headers });`,
+ 30,
+ 70 + line * 20,
+ )
+ }
+ const blob = await new Promise((resolve) => canvas.toBlob((blob) => resolve(blob!), "image/png"))
+ screenshots.push({ id, blob })
+}
+const transaction = db.transaction(["documents", "blobs"], "readwrite")
+transaction.objectStore("documents").put(JSON.stringify({ entries: normal }), "opencode.global.dat:prompt-history")
+transaction.objectStore("documents").put(JSON.stringify({ entries: shell }), "opencode.global.dat:prompt-history-shell")
+screenshots.forEach(({ id, blob }) => transaction.objectStore("blobs").put(blob, id))
+await new Promise((resolve, reject) => {
+ transaction.oncomplete = () => resolve()
+ transaction.onerror = () => reject(transaction.error)
+})
+db.close()
+
+const metrics = { reads: 0, blobBytes: 0, documents: 0 }
+const originalGet = IDBObjectStore.prototype.get
+IDBObjectStore.prototype.get = function (key) {
+ const request = originalGet.call(this, key)
+ if (this.name === "documents") metrics.documents++
+ if (this.name === "blobs") {
+ metrics.reads++
+ request.addEventListener("success", () => {
+ metrics.blobBytes += request.result?.size ?? 0
+ })
+ }
+ return request
+}
+const platform: Platform = {
+ platform: "web",
+ draftStore: createBrowserDraftStore(),
+ openExternal() {},
+ restart: async () => {},
+ notify: async () => {},
+}
+const [state, setState] = createStore({ mount: 0, ready: false, result: "" })
+const workload = {
+ shape,
+ normalEntries: normal.length,
+ shellEntries: shell.length,
+ imageReferences: shape === "text" ? 0 : 50,
+ uniqueImages: ids.length,
+ storedImageBytes: screenshots.reduce((sum, item) => sum + item.blob.size, 0),
+ documentBytes: [normal, shell].reduce(
+ (sum, entries) => sum + new TextEncoder().encode(JSON.stringify({ entries })).length,
+ 0,
+ ),
+ screenshotDimensions: [1440, 900],
+}
+let started = 0
+function mount() {
+ metrics.reads = 0
+ metrics.blobBytes = 0
+ metrics.documents = 0
+ setState({ ready: false, result: "" })
+ started = performance.now()
+ setState("mount", state.mount + 1)
+}
+function Destination() {
+ // Same history creation and editor mapping as createComposerModel. Destination draft is empty.
+ const history = createComposerHistory()
+ const store = createStore({
+ prompt: [{ type: "text", content: "", start: 0, end: 0 }],
+ cursor: 0,
+ context: { items: [] },
+ })
+ const controller = createComposerEditor({
+ store,
+ commands: () => [],
+ context: () => [],
+ searchContextFiles: () => [],
+ history: {
+ entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
+ add: (prompt, mode) => history.add(prompt, mode, []),
+ },
+ view: {
+ placeholder: () => "Empty destination composer",
+ submit: { stopping: () => false, onSubmit() {}, onStop() {} },
+ },
+ })
+ createEffect(() => {
+ if (history.entries("normal").length !== 100 || history.entries("shell").length !== 100) return
+ setState({
+ ready: true,
+ result: JSON.stringify({ historyReadyMs: performance.now() - started, ...metrics, ...workload }),
+ })
+ })
+ return
+}
+render(
+ () => (
+
+
+ Composer global history: {shape}
+
+
+ {state.result}
+
+ {(_mount) => }
+
+
+
+ ),
+ document.getElementById("root")!,
+)
diff --git a/packages/app/e2e/performance/composer-history/index.html b/packages/app/e2e/performance/composer-history/index.html
new file mode 100644
index 00000000000..18da871376a
--- /dev/null
+++ b/packages/app/e2e/performance/composer-history/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Composer history benchmark
+
+
+
+
+
+
diff --git a/packages/app/e2e/performance/composer-history/playwright.config.ts b/packages/app/e2e/performance/composer-history/playwright.config.ts
new file mode 100644
index 00000000000..45a0e5a4a1c
--- /dev/null
+++ b/packages/app/e2e/performance/composer-history/playwright.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from "@playwright/test"
+import { fileURLToPath } from "node:url"
+
+export default defineConfig({
+ testDir: ".",
+ testMatch: "composer-history.bench.ts",
+ workers: 1,
+ retries: 0,
+ timeout: 60_000,
+ reporter: "line",
+ outputDir: process.env.OPENCODE_HISTORY_OUTPUT,
+ use: { baseURL: "http://127.0.0.1:4783", viewport: { width: 1440, height: 900 }, trace: "off", video: "off" },
+ webServer: {
+ cwd: fileURLToPath(new URL("../../../", import.meta.url)),
+ command:
+ "bun x vite preview --config e2e/performance/composer-history/vite.config.ts --host 127.0.0.1 --port 4783 --strictPort",
+ url: "http://127.0.0.1:4783",
+ reuseExistingServer: false,
+ },
+})
diff --git a/packages/app/e2e/performance/composer-history/vite.config.ts b/packages/app/e2e/performance/composer-history/vite.config.ts
new file mode 100644
index 00000000000..7d1d96f67d1
--- /dev/null
+++ b/packages/app/e2e/performance/composer-history/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vite"
+import { fileURLToPath } from "node:url"
+import app from "../../../vite"
+
+export default defineConfig({
+ root: fileURLToPath(new URL(".", import.meta.url)),
+ publicDir: fileURLToPath(new URL("../../../public", import.meta.url)),
+ plugins: [app],
+ build: { target: "esnext", outDir: process.env.OPENCODE_HISTORY_BUILD, emptyOutDir: true },
+})
diff --git a/packages/app/src/runtime/persistence/drafts.ts b/packages/app/src/runtime/persistence/drafts.ts
index c16694a7dac..544f21ddd3d 100644
--- a/packages/app/src/runtime/persistence/drafts.ts
+++ b/packages/app/src/runtime/persistence/drafts.ts
@@ -39,6 +39,19 @@ export async function createBlobReference(blob: Blob): Promise {
export function createDraftStore(driver: Driver): DraftStore {
const versions = new Map()
+ const loading = new Map>()
+ const loadBlobUrl = (id: string) => {
+ const existing = urls.get(id)
+ if (existing) return existing
+ const pending = loading.get(id)
+ if (pending) return pending
+ const next = driver
+ .getBlob(id)
+ .then((blob) => (blob ? blobUrl(id, blob) : undefined))
+ .finally(() => loading.delete(id))
+ loading.set(id, next)
+ return next
+ }
const putBlob = async (blob: Blob) => {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob) }
@@ -71,8 +84,8 @@ export function createDraftStore(driver: Driver): DraftStore {
if (item.blob && typeof item.blob === "object") {
const ref = item.blob as Record
if (typeof ref.id === "string") {
- const blob = await driver.getBlob(ref.id)
- if (blob) return { ...item, blob: { id: ref.id, url: blobUrl(ref.id, blob) } }
+ const url = await loadBlobUrl(ref.id)
+ if (url) return { ...item, blob: { id: ref.id, url } }
}
}
return Object.fromEntries(
diff --git a/packages/app/test-browser/draft-history-cache.test.ts b/packages/app/test-browser/draft-history-cache.test.ts
new file mode 100644
index 00000000000..3a29415dce4
--- /dev/null
+++ b/packages/app/test-browser/draft-history-cache.test.ts
@@ -0,0 +1,130 @@
+import { expect, test } from "bun:test"
+import { resolveObjectURL } from "node:buffer"
+import { createDraftStore } from "@/runtime/persistence/drafts"
+
+function fixture(id: string, getBlob: () => Promise) {
+ const documents = new Map([
+ ["history", JSON.stringify({ entries: [{ prompt: [{ type: "image", blob: { id } }] }] })],
+ ["draft", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] })],
+ ])
+ const store = createDraftStore({
+ get: async (key) => documents.get(key) ?? null,
+ set: async (key, value) => void documents.set(key, value),
+ remove: async (key) => void documents.delete(key),
+ putBlob: async () => id,
+ getBlob,
+ })
+ return { store, documents }
+}
+
+test("deduplicates concurrent history and draft reads without invalidating either live reference", async () => {
+ const pending = Promise.withResolvers()
+ const started = Promise.withResolvers()
+ let reads = 0
+ const { store } = fixture("history-cache-concurrent", () => {
+ reads++
+ started.resolve()
+ return pending.promise
+ })
+ const history = store.getItem("history")
+ const draft = store.getItem("draft")
+ await started.promise
+ pending.resolve(new Blob(["shared screenshot"]))
+ const [saved, active] = await Promise.all([history, draft])
+ const reference = JSON.parse(saved!).entries[0].prompt[0].blob
+ expect(JSON.parse(active!).prompt[0].blob).toEqual(reference)
+ expect(reads).toBe(1)
+ await store.removeItem("history")
+ expect(await resolveObjectURL(reference.url)?.text()).toBe("shared screenshot")
+ expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
+ expect(reads).toBe(1)
+})
+
+test("hydrates repeated references once within one history document", async () => {
+ let reads = 0
+ const { store, documents } = fixture("history-cache-repeated", async () => {
+ reads++
+ return new Blob(["repeated screenshot"])
+ })
+ documents.set(
+ "history",
+ JSON.stringify({
+ entries: Array.from({ length: 100 }, () => ({
+ prompt: [{ type: "image", blob: { id: "history-cache-repeated" } }],
+ })),
+ }),
+ )
+ const value = JSON.parse((await store.getItem("history"))!)
+ expect(value.entries).toHaveLength(100)
+ expect(
+ new Set(value.entries.map((entry: { prompt: { blob: { url: string } }[] }) => entry.prompt[0].blob.url)).size,
+ ).toBe(1)
+ expect(reads).toBe(1)
+})
+
+test("reuses a live URL on remount but reads the latest document", async () => {
+ let reads = 0
+ const { store, documents } = fixture("history-cache-remount", async () => {
+ reads++
+ return new Blob(["saved screenshot"])
+ })
+ const first = JSON.parse((await store.getItem("history"))!)
+ const changed = JSON.parse(documents.get("history")!)
+ changed.entries[0].prompt.unshift({ type: "text", content: "new admission" })
+ documents.set("history", JSON.stringify(changed))
+ const second = JSON.parse((await store.getItem("history"))!)
+ expect(second.entries[0].prompt[0].content).toBe("new admission")
+ expect(second.entries[0].prompt[1].blob).toEqual(first.entries[0].prompt[0].blob)
+ expect(reads).toBe(1)
+})
+
+test("reuses a just-stored attachment without a round trip", async () => {
+ let reads = 0
+ const { store } = fixture("history-cache-put", async () => {
+ reads++
+ return new Blob(["unexpected read"])
+ })
+ const reference = await store.putBlob(new Blob(["pending admission"]))
+ expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
+ expect(reads).toBe(0)
+ expect(await resolveObjectURL(reference.url)?.text()).toBe("pending admission")
+})
+
+test("does not retain a missing blob result", async () => {
+ let reads = 0
+ const { store } = fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
+ expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toBeUndefined()
+ expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toStartWith("blob:")
+ expect(reads).toBe(2)
+})
+
+test("retries after a failed blob read", async () => {
+ let reads = 0
+ const { store } = fixture("history-cache-failure", async () => {
+ if (++reads === 1) throw new Error("temporary storage failure")
+ return new Blob(["recovered"])
+ })
+ await expect(store.getItem("history")).rejects.toThrow("temporary storage failure")
+ expect(JSON.parse((await store.getItem("history"))!).entries[0].prompt[0].blob.url).toStartWith("blob:")
+ expect(reads).toBe(2)
+})
+
+test("keeps different blob IDs independent", async () => {
+ const reads: string[] = []
+ const store = createDraftStore({
+ get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))),
+ set: async () => {},
+ remove: async () => {},
+ putBlob: async () => "unused",
+ getBlob: async (id) => {
+ reads.push(id)
+ return new Blob([id])
+ },
+ })
+ const value = JSON.parse((await store.getItem("history"))!)
+ expect(value[0].blob.url).not.toBe(value[1].blob.url)
+ expect(
+ await Promise.all(value.map((item: { blob: { url: string } }) => resolveObjectURL(item.blob.url)?.text())),
+ ).toEqual(reads)
+ expect(reads).toEqual(["history-cache-first", "history-cache-second"])
+})