Skip to content

Commit fa4f8a6

Browse files
authored
fix(app): reuse hydrated composer history blobs (#46761)
1 parent 9391ee8 commit fa4f8a6

8 files changed

Lines changed: 445 additions & 2 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Composer History Hydration
2+
3+
Manual benchmark for an empty destination composer. Runs the production
4+
`ComposerEditor`, `createComposerEditor`, `createComposerHistory`, persistence
5+
codec, and browser IndexedDB draft store. It does not run the surrounding app
6+
shell or native desktop IPC.
7+
8+
Workload: 100 normal prompts with realistic review instructions/code and 100
9+
shell commands. Separate cases have no images, 50 unique screenshots, or 50
10+
references to 5 screenshots. The fixture generates valid 1440 x 900 PNG code
11+
screenshots before timing and reports their exact byte sizes. Each isolated
12+
browser context measures a cold URL-cache mount followed by a warm remount.
13+
The database was just seeded; this does not simulate a cold disk cache.
14+
15+
`historyReadyMs` measures the mount action until both production history stores
16+
are populated. This is history availability, not time to first editable input
17+
(input can be usable before history finishes). The benchmark then verifies
18+
ArrowUp recall and a decoded screenshot in the real editor. `recallObservedMs`
19+
includes Playwright action/assertion overhead and is reported separately.
20+
`mountRecallObservedMs` includes the mount, readiness checks, keyboard action,
21+
and correct text/image completion; it also includes Playwright overhead.
22+
IndexedDB reads and blob sizes are mechanism metrics, not desktop IPC bytes or
23+
process memory. No timing threshold is enforced.
24+
25+
From `packages/app`, set `OPENCODE_HISTORY_BUILD` and
26+
`OPENCODE_HISTORY_OUTPUT` to artifact directories outside Git, then run:
27+
28+
```sh
29+
bun x vite build --config e2e/performance/composer-history/vite.config.ts
30+
bun x playwright test --config e2e/performance/composer-history/playwright.config.ts --repeat-each=20
31+
```
32+
33+
The preview server owns port 4783 and is stopped by Playwright. Preserve each
34+
build and its revision/hash for comparisons. `BENCHMARK` JSON lines contain all
35+
raw samples. Optional Chrome traces use the existing
36+
`OPENCODE_PERFORMANCE_TRACE_DIR` setting; keep trace runs separate from clean
37+
timing. Screenshots are captured after timing on the first repeat only.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { benchmark, expect } from "../benchmark"
2+
3+
benchmark.use({ traceScope: "page" })
4+
for (const shape of ["text", "unique", "repeated"]) {
5+
benchmark(`composer global history: ${shape}, cold and warm mounts`, async ({ page, report }, testInfo) => {
6+
const errors: string[] = []
7+
page.on("pageerror", (error) => errors.push(error.message))
8+
await page.goto(`/?shape=${shape}`)
9+
const button = page.getByRole("button", { name: "Mount empty composer", exact: true })
10+
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
11+
const samples = []
12+
for (const cache of ["cold", "warm"]) {
13+
await expect(button).toBeEnabled()
14+
const mountStarted = performance.now()
15+
await button.click()
16+
await expect(page.getByTestId("history-ready")).toHaveText("ready")
17+
await expect(input).toBeEditable()
18+
await expect(input).toBeEmpty()
19+
const result = JSON.parse((await page.getByTestId("history-result").textContent())!)
20+
expect(result.documents).toBe(2)
21+
expect(result.historyReadyMs).toBeGreaterThan(0)
22+
const start = performance.now()
23+
await input.press("ArrowUp")
24+
await expect(input).toContainText("Review the retry policy in src/network/request-0.ts.")
25+
const images = page.getByRole("img", { name: "request-0.png", exact: true })
26+
await expect(images).toHaveCount(shape === "text" ? 0 : 1)
27+
if (shape !== "text")
28+
await expect
29+
.poll(() => images.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth === 1440))
30+
.toBe(true)
31+
samples.push({
32+
cache,
33+
...result,
34+
recallObservedMs: performance.now() - start,
35+
mountRecallObservedMs: performance.now() - mountStarted,
36+
})
37+
}
38+
expect(errors).toEqual([])
39+
report(
40+
{ samples },
41+
{
42+
browser: page.context().browser()!.version(),
43+
scope: "production composer editor/history, browser IndexedDB; no native IPC",
44+
},
45+
)
46+
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: testInfo.outputPath(`${shape}.png`) })
47+
})
48+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/// <reference types="vite/client" />
2+
import { createEffect, Show } from "solid-js"
3+
import { createStore } from "solid-js/store"
4+
import { render } from "solid-js/web"
5+
import { PlatformProvider, type Platform } from "@/runtime/platform/platform"
6+
import { createBrowserDraftStore } from "@/runtime/persistence/drafts"
7+
import { createComposerHistory } from "@/composer/history/store"
8+
import { ComposerEditor } from "@/composer/editor/editor"
9+
import { createComposerEditor } from "@/composer/editor/interaction"
10+
import type { ComposerPersistedState } from "@/composer/types"
11+
import "@/index.css"
12+
13+
const shape = new URLSearchParams(location.search).get("shape") ?? "text"
14+
const normal = Array.from({ length: 100 }, (_, index) => {
15+
const content =
16+
`Review the retry policy in src/network/request-${index}.ts. Preserve cancellation and the existing error messages.\n\n` +
17+
`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` +
18+
`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\`\`\``
19+
return {
20+
prompt: [
21+
{ type: "text", content, start: 0, end: content.length },
22+
...(shape !== "text" && index % 2 === 0
23+
? [
24+
{
25+
type: "image",
26+
id: `attachment-${index}`,
27+
filename: `request-${index}.png`,
28+
mime: "image/png",
29+
blob: { id: `screenshot-${shape === "repeated" ? index % 10 : index}` },
30+
},
31+
]
32+
: []),
33+
],
34+
comments: [],
35+
}
36+
})
37+
const shell = Array.from({ length: 100 }, (_, index) => {
38+
const content = `bun test src/network/request-${index}.test.ts --timeout 30000`
39+
return { prompt: [{ type: "text", content, start: 0, end: content.length }], comments: [] }
40+
})
41+
42+
// Seed only this Playwright context, before opening the production draft store.
43+
const request = indexedDB.open("opencode-drafts", 1)
44+
request.onupgradeneeded = () => {
45+
request.result.createObjectStore("documents")
46+
request.result.createObjectStore("blobs")
47+
}
48+
const db = await new Promise<IDBDatabase>((resolve, reject) => {
49+
request.onsuccess = () => resolve(request.result)
50+
request.onerror = () => reject(request.error)
51+
})
52+
const ids = [...new Set(normal.flatMap((entry) => entry.prompt.flatMap((part) => (part.blob ? [part.blob.id] : []))))]
53+
const screenshots: { id: string; blob: Blob }[] = []
54+
for (const id of ids) {
55+
const canvas = document.createElement("canvas")
56+
canvas.width = 1440
57+
canvas.height = 900
58+
const context = canvas.getContext("2d")!
59+
context.fillStyle = "#15191f"
60+
context.fillRect(0, 0, canvas.width, canvas.height)
61+
context.font = "16px monospace"
62+
context.fillStyle = "#b8c8d8"
63+
context.fillText(`request.ts - ${id}`, 30, 35)
64+
for (let line = 0; line < 38; line++) {
65+
context.fillStyle = line % 3 ? "#a8c7ba" : "#d4a882"
66+
context.fillText(
67+
`${String(line + 1).padStart(3)} const response${line} = await fetch('/api/request/${id}/${line}', { signal, headers });`,
68+
30,
69+
70 + line * 20,
70+
)
71+
}
72+
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((blob) => resolve(blob!), "image/png"))
73+
screenshots.push({ id, blob })
74+
}
75+
const transaction = db.transaction(["documents", "blobs"], "readwrite")
76+
transaction.objectStore("documents").put(JSON.stringify({ entries: normal }), "opencode.global.dat:prompt-history")
77+
transaction.objectStore("documents").put(JSON.stringify({ entries: shell }), "opencode.global.dat:prompt-history-shell")
78+
screenshots.forEach(({ id, blob }) => transaction.objectStore("blobs").put(blob, id))
79+
await new Promise<void>((resolve, reject) => {
80+
transaction.oncomplete = () => resolve()
81+
transaction.onerror = () => reject(transaction.error)
82+
})
83+
db.close()
84+
85+
const metrics = { reads: 0, blobBytes: 0, documents: 0 }
86+
const originalGet = IDBObjectStore.prototype.get
87+
IDBObjectStore.prototype.get = function (key) {
88+
const request = originalGet.call(this, key)
89+
if (this.name === "documents") metrics.documents++
90+
if (this.name === "blobs") {
91+
metrics.reads++
92+
request.addEventListener("success", () => {
93+
metrics.blobBytes += request.result?.size ?? 0
94+
})
95+
}
96+
return request
97+
}
98+
const platform: Platform = {
99+
platform: "web",
100+
draftStore: createBrowserDraftStore(),
101+
openExternal() {},
102+
restart: async () => {},
103+
notify: async () => {},
104+
}
105+
const [state, setState] = createStore({ mount: 0, ready: false, result: "" })
106+
const workload = {
107+
shape,
108+
normalEntries: normal.length,
109+
shellEntries: shell.length,
110+
imageReferences: shape === "text" ? 0 : 50,
111+
uniqueImages: ids.length,
112+
storedImageBytes: screenshots.reduce((sum, item) => sum + item.blob.size, 0),
113+
documentBytes: [normal, shell].reduce(
114+
(sum, entries) => sum + new TextEncoder().encode(JSON.stringify({ entries })).length,
115+
0,
116+
),
117+
screenshotDimensions: [1440, 900],
118+
}
119+
let started = 0
120+
function mount() {
121+
metrics.reads = 0
122+
metrics.blobBytes = 0
123+
metrics.documents = 0
124+
setState({ ready: false, result: "" })
125+
started = performance.now()
126+
setState("mount", state.mount + 1)
127+
}
128+
function Destination() {
129+
// Same history creation and editor mapping as createComposerModel. Destination draft is empty.
130+
const history = createComposerHistory()
131+
const store = createStore<ComposerPersistedState>({
132+
prompt: [{ type: "text", content: "", start: 0, end: 0 }],
133+
cursor: 0,
134+
context: { items: [] },
135+
})
136+
const controller = createComposerEditor({
137+
store,
138+
commands: () => [],
139+
context: () => [],
140+
searchContextFiles: () => [],
141+
history: {
142+
entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
143+
add: (prompt, mode) => history.add(prompt, mode, []),
144+
},
145+
view: {
146+
placeholder: () => "Empty destination composer",
147+
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
148+
},
149+
})
150+
createEffect(() => {
151+
if (history.entries("normal").length !== 100 || history.entries("shell").length !== 100) return
152+
setState({
153+
ready: true,
154+
result: JSON.stringify({ historyReadyMs: performance.now() - started, ...metrics, ...workload }),
155+
})
156+
})
157+
return <ComposerEditor controller={controller} />
158+
}
159+
render(
160+
() => (
161+
<PlatformProvider value={platform}>
162+
<main style={{ padding: "40px", width: "900px" }}>
163+
<h1>Composer global history: {shape}</h1>
164+
<button onClick={mount}>Mount empty composer</button>
165+
<output data-testid="history-ready">{state.ready ? "ready" : "idle"}</output>
166+
<pre data-testid="history-result">{state.result}</pre>
167+
<Show when={state.mount} keyed>
168+
{(_mount) => <Destination />}
169+
</Show>
170+
</main>
171+
</PlatformProvider>
172+
),
173+
document.getElementById("root")!,
174+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Composer history benchmark</title>
6+
</head>
7+
<body>
8+
<div id="root"></div>
9+
<script type="module" src="./fixture.tsx"></script>
10+
</body>
11+
</html>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { defineConfig } from "@playwright/test"
2+
import { fileURLToPath } from "node:url"
3+
4+
export default defineConfig({
5+
testDir: ".",
6+
testMatch: "composer-history.bench.ts",
7+
workers: 1,
8+
retries: 0,
9+
timeout: 60_000,
10+
reporter: "line",
11+
outputDir: process.env.OPENCODE_HISTORY_OUTPUT,
12+
use: { baseURL: "http://127.0.0.1:4783", viewport: { width: 1440, height: 900 }, trace: "off", video: "off" },
13+
webServer: {
14+
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
15+
command:
16+
"bun x vite preview --config e2e/performance/composer-history/vite.config.ts --host 127.0.0.1 --port 4783 --strictPort",
17+
url: "http://127.0.0.1:4783",
18+
reuseExistingServer: false,
19+
},
20+
})
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineConfig } from "vite"
2+
import { fileURLToPath } from "node:url"
3+
import app from "../../../vite"
4+
5+
export default defineConfig({
6+
root: fileURLToPath(new URL(".", import.meta.url)),
7+
publicDir: fileURLToPath(new URL("../../../public", import.meta.url)),
8+
plugins: [app],
9+
build: { target: "esnext", outDir: process.env.OPENCODE_HISTORY_BUILD, emptyOutDir: true },
10+
})

packages/app/src/runtime/persistence/drafts.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ export async function createBlobReference(blob: Blob): Promise<BlobReference> {
3939

4040
export function createDraftStore(driver: Driver): DraftStore {
4141
const versions = new Map<string, number>()
42+
const loading = new Map<string, Promise<string | undefined>>()
43+
const loadBlobUrl = (id: string) => {
44+
const existing = urls.get(id)
45+
if (existing) return existing
46+
const pending = loading.get(id)
47+
if (pending) return pending
48+
const next = driver
49+
.getBlob(id)
50+
.then((blob) => (blob ? blobUrl(id, blob) : undefined))
51+
.finally(() => loading.delete(id))
52+
loading.set(id, next)
53+
return next
54+
}
4255
const putBlob = async (blob: Blob) => {
4356
const id = await driver.putBlob(blob)
4457
return { id, url: blobUrl(id, blob) }
@@ -71,8 +84,8 @@ export function createDraftStore(driver: Driver): DraftStore {
7184
if (item.blob && typeof item.blob === "object") {
7285
const ref = item.blob as Record<string, unknown>
7386
if (typeof ref.id === "string") {
74-
const blob = await driver.getBlob(ref.id)
75-
if (blob) return { ...item, blob: { id: ref.id, url: blobUrl(ref.id, blob) } }
87+
const url = await loadBlobUrl(ref.id)
88+
if (url) return { ...item, blob: { id: ref.id, url } }
7689
}
7790
}
7891
return Object.fromEntries(

0 commit comments

Comments
 (0)