Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { benchmark, expect } from "../benchmark"
import { openComposerSession } from "./composer-session"
import {
buildFixtureText,
measureComposerPaste,
median,
percentile,
type ComposerPasteFixture,
type ComposerPasteSample,
} from "./composer-paste-probe"

const iterations = Number(process.env.OPENCODE_PASTE_ITERATIONS ?? 5)

const fixtures: ComposerPasteFixture[] = [
{ label: "5KiB-multiline", chars: 5 * 1024, lines: 60 },
{ label: "64KiB-multiline", chars: 64 * 1024, lines: 800 },
{ label: "100KiB-log", chars: 100 * 1024, lines: 1200 },
{ label: "256KiB-log", chars: 256 * 1024, lines: 3000 },
{ label: "1MiB-log", chars: 1024 * 1024, lines: 12000 },
{ label: "1MiB-single-line", chars: 1024 * 1024, lines: 1 },
{ label: "256KiB-crlf", chars: 256 * 1024, lines: 3000, crlf: true },
]

function summarize(samples: ComposerPasteSample[]) {
const settle = samples.map((sample) => sample.settleMs)
const dispatch = samples.map((sample) => sample.dispatchMs)
const blocked = samples.map((sample) => sample.blockedMs)
return {
iterations: samples.length,
chars: samples[0]?.chars ?? 0,
dispatchMedianMs: median(dispatch),
settleMedianMs: median(settle),
settleP95Ms: percentile(settle, 0.95),
settleMaxMs: Math.max(...settle),
blockedMedianMs: median(blocked),
longestTaskMaxMs: Math.max(...samples.map((sample) => sample.longestTaskMs)),
editorElementsMax: Math.max(...samples.map((sample) => sample.editorElements)),
lossless: samples.every((sample) => sample.lossless),
}
}

// A composer that stalls the renderer never returns from its paste, so every fixture runs as
// its own test with its own page. One hung tier then costs only its own measurement instead
// of every tier queued behind it.
const FIXTURE_TIMEOUT_MS = 3 * 60_000

for (const composer of ["v2", "legacy"] as const) {
for (const fixture of fixtures) {
benchmark(`composer paste ${composer} ${fixture.label}`, async ({ page, report }) => {
benchmark.setTimeout(FIXTURE_TIMEOUT_MS)
const selector = await openComposerSession(page, composer === "v2")
const text = buildFixtureText(fixture)

const samples: ComposerPasteSample[] = []
for (let index = 0; index < iterations; index += 1) {
samples.push(await measureComposerPaste(page, selector, text))
await page.evaluate((target) => {
const editor = document.querySelector(target)
if (!(editor instanceof HTMLElement)) return
const range = document.createRange()
range.selectNodeContents(editor)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
document.execCommand("delete")
}, selector)
await page.waitForTimeout(150)
}

const summary = summarize(samples)
report(summary, { composer, fixture: fixture.label, chars: fixture.chars, lines: fixture.lines })

// Asserted after reporting so a lossy paste still leaves its measurements behind.
expect(summary.lossless, `${composer} ${fixture.label} must round-trip losslessly`).toBe(true)
})
}
}
157 changes: 157 additions & 0 deletions packages/app/e2e/performance/composer/composer-paste-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { Page } from "@playwright/test"

export type ComposerPasteFixture = {
label: string
chars: number
lines: number
crlf?: boolean
}

export type ComposerPasteSample = {
chars: number
dispatchMs: number
settleMs: number
longTasks: number
longestTaskMs: number
blockedMs: number
editorElements: number
editorTextLength: number
lossless: boolean
}

export function buildFixtureText(fixture: ComposerPasteFixture) {
const newline = fixture.crlf ? "\r\n" : "\n"
if (fixture.lines <= 1)
return "abcdefghij klmnopqrst uvwxyz0123 ".repeat(Math.ceil(fixture.chars / 33)).slice(0, fixture.chars)
const per = Math.max(1, Math.floor(fixture.chars / fixture.lines) - newline.length)
const body = "abcdefghij klmnopqrst uvwxyz0123 ".repeat(Math.ceil(per / 33))
return Array.from({ length: fixture.lines }, (_, index) => `${index} ${body}`.slice(0, per)).join(newline)
}

// Measured entirely inside the page: the CDP transfer of the payload happens before
// the observation window opens, so it never contributes to the reported latency.
export async function measureComposerPaste(page: Page, selector: string, text: string): Promise<ComposerPasteSample> {
return page.evaluate(
async ([target, payload]) => {
const editor = document.querySelector(target)
if (!(editor instanceof HTMLElement)) throw new Error(`No composer editor for ${target}`)

const longTasks: number[] = []
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => longTasks.push(entry.duration))
})
observer.observe({ entryTypes: ["longtask"] })

editor.focus()
const selection = window.getSelection()
const caret = document.createRange()
caret.selectNodeContents(editor)
caret.collapse(false)
selection?.removeAllRanges()
selection?.addRange(caret)

const transfer = new DataTransfer()
transfer.setData("text/plain", payload)

const start = performance.now()
editor.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer }))
const dispatched = performance.now()

// Two consecutive frames means reactive effects, editor reconciliation, layout
// and paint have all drained, so the editor is interactive again.
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
const settled = performance.now()
observer.disconnect()

// textContent drops line breaks, which both composers model as <br> (and which
// Chromium additionally wraps in block containers), so read the editor the same
// way the composers parse it back into prompt parts.
const blocks = new Set(["DIV", "P", "LI", "PRE"])
const read = (root: Node) => {
let out = ""
const walk = (node: Node) => {
node.childNodes.forEach((child) => {
if (child.nodeType === Node.TEXT_NODE) {
out += child.nodeValue ?? ""
return
}
if (!(child instanceof HTMLElement)) return
if (child.tagName === "BR") {
out += "\n"
return
}
if (out.length > 0 && blocks.has(child.tagName)) out += "\n"
walk(child)
})
}
walk(root)
return out
}

const expected = payload.replace(/\r\n?/g, "\n")
const actual = read(editor)
.replace(/\u200B/g, "")
.replace(/\r\n?/g, "\n")
return {
chars: payload.length,
dispatchMs: dispatched - start,
settleMs: settled - start,
longTasks: longTasks.length,
longestTaskMs: longTasks.reduce((max, value) => Math.max(max, value), 0),
blockedMs: longTasks.reduce((total, value) => total + value, 0),
editorElements: editor.querySelectorAll("*").length,
editorTextLength: actual.length,
lossless: actual.endsWith(expected),
}
},
[selector, text] as const,
)
}

export type ComposerTypingSample = { syncMs: number; settleMs: number }

// A paste is a single event, but the draft it leaves behind is what every following keystroke
// has to walk. Typing is measured separately so a fast paste cannot hide a composer that has
// become unusable afterwards.
export async function measureComposerTyping(page: Page, selector: string, keystrokes: number) {
return page.evaluate(
async ([target, count]) => {
const editor = document.querySelector(target)
if (!(editor instanceof HTMLElement)) throw new Error(`No composer editor for ${target}`)
editor.focus()
const caret = document.createRange()
caret.selectNodeContents(editor)
caret.collapse(false)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(caret)

const samples: { syncMs: number; settleMs: number }[] = []
for (let index = 0; index < count; index += 1) {
const start = performance.now()
document.execCommand("insertText", false, "x")
// The input handler runs synchronously inside execCommand, so this is the part of a
// keystroke that the draft size can make unbounded.
const sync = performance.now()
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
samples.push({ syncMs: sync - start, settleMs: performance.now() - start })
}
return samples
},
[selector, keystrokes] as const,
)
}

export function median(values: number[]) {
const sorted = [...values].sort((a, b) => a - b)
if (sorted.length === 0) return 0
const middle = Math.floor(sorted.length / 2)
if (sorted.length % 2 === 1) return sorted[middle]!
return (sorted[middle - 1]! + sorted[middle]!) / 2
}

export function percentile(values: number[], fraction: number) {
const sorted = [...values].sort((a, b) => a - b)
if (sorted.length === 0) return 0
return sorted[Math.min(sorted.length - 1, Math.ceil(fraction * sorted.length) - 1)]!
}
54 changes: 54 additions & 0 deletions packages/app/e2e/performance/composer/composer-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import type { Page } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectAppVisible } from "../../utils/waits"
import { expect } from "../benchmark"

const directory = "C:/OpenCode/ComposerPasteBenchmark"
const projectID = "proj_composer_paste_benchmark"
const sessionID = "ses_composer_paste_benchmark"

// Opens a session against the mock server with the requested composer, and returns the
// selector both composers put on their editor.
export async function openComposerSession(page: Page, newLayout: boolean) {
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "composer-paste-benchmark",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [
{
id: sessionID,
slug: "composer-paste-benchmark",
projectID,
directory,
title: "Composer paste benchmark",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript((layout) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { newLayoutDesigns: layout, layoutTransitionEligible: true } }),
)
// A profile that has never launched the app counts as an upgrade, and the upgrade
// migration forces the new layout on regardless of the stored preference.
localStorage.setItem("app-version.v1", JSON.stringify({ version: "99.0.0" }))
}, newLayout)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]').first())
// Both composers mark their editor with the same attribute, so measuring the wrong one is
// invisible in the results unless the V2 wrapper is checked explicitly. The layout only
// settles once the persisted settings resolve, so this has to be a retrying assertion.
await expect(page.locator('[data-component="prompt-input-v2"]')).toHaveCount(newLayout ? 1 : 0)
return '[data-component="prompt-input"]'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { benchmark } from "../benchmark"
import { openComposerSession } from "./composer-session"
import {
buildFixtureText,
measureComposerPaste,
measureComposerTyping,
median,
percentile,
type ComposerPasteFixture,
} from "./composer-paste-probe"

const keystrokes = Number(process.env.OPENCODE_TYPING_KEYSTROKES ?? 20)

const fixtures: ComposerPasteFixture[] = [
{ label: "64KiB-multiline", chars: 64 * 1024, lines: 800 },
{ label: "100KiB-log", chars: 100 * 1024, lines: 1200 },
]

for (const composer of ["v2", "legacy"] as const) {
for (const fixture of fixtures) {
benchmark(`composer typing after paste ${composer} ${fixture.label}`, async ({ page, report }) => {
benchmark.setTimeout(5 * 60_000)
const selector = await openComposerSession(page, composer === "v2")
await measureComposerPaste(page, selector, buildFixtureText(fixture))

const samples = await measureComposerTyping(page, selector, keystrokes)
const sync = samples.map((sample) => sample.syncMs)
const settle = samples.map((sample) => sample.settleMs)
report(
{
keystrokes: samples.length,
syncMedianMs: median(sync),
syncP95Ms: percentile(sync, 0.95),
syncMaxMs: Math.max(...sync),
settleMedianMs: median(settle),
settleP95Ms: percentile(settle, 0.95),
},
{ composer, fixture: fixture.label, chars: fixture.chars, lines: fixture.lines },
)
})
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { attachmentMime, pickAttachmentFiles } from "./files"
import { pasteMode } from "./paste"
import { pasteMode } from "@opencode-ai/session-ui/v2/prompt-input/paste"

describe("attachmentMime", () => {
test("keeps PDFs when the browser reports the mime", async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/components/prompt-input/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
import { createBlobReference, type DraftStore } from "@/utils/draft-store"
import { attachmentMime } from "./files"
import { normalizePaste, pasteMode } from "./paste"
import { normalizePaste, pasteMode } from "@opencode-ai/session-ui/v2/prompt-input/paste"

type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
Expand Down
Loading
Loading