Compare commits

...
50 changed files with 2243 additions and 185 deletions
@@ -0,0 +1,41 @@
# Patch Group Benchmark
This manual benchmark mounts the production `CurrentFileToolGroup` and `File`
components with completed edit results. A separate case mounts `ToolDisplay`
with a patch result. It uses four real Core tool source files, with deterministic
identifier renames, rather than repeated filler. It does not connect to a server.
From `packages/app`, set `PATCH_BUILD_DIR` and `PATCH_RESULTS_DIR` to external
artifact directories, then run:
```sh
bun x vite build --config e2e/performance/patch-groups/vite.config.ts
bun x playwright test --config e2e/performance/patch-groups/playwright.config.ts --repeat-each=20
```
Run under the shared exclusive gate when collecting measurements on a shared
machine. The Playwright-owned static server uses `PATCH_PORT` (default 4317),
refuses to reuse an existing server, and shuts down after the run.
Each fresh browser context measures a cold collapsed mount, a warm remount,
and opening `edit.ts` through its real accordion. Mount timing covers synchronous
component construction through layout. Expansion timing starts at the click and
ends at the production file renderer's `onRendered` callback. Assertions check
the exact file count, collapsed state, and completed file rendering. Results
include payload bytes, source bytes, file/tool counts, and supporting warm
`patchFileGroups` timings with and without reading views. No timing thresholds
are enforced. This is a browser component workload, not a full desktop memory test.
Freeze the build before changing production code. Use the same fixture, browser,
viewport, sample count, and completion checks for both revisions.
`PATCH_REVISION=<git-sha>` loads the grouping module and tool renderer from that
revision at build time without changing the worktree. This is useful when fixing
the harness after freezing a baseline. All other production sources must match
between revisions; this switch only covers those two measured modules.
For a separate diagnostic build, set `PATCH_COUNTERS=1`. Its build-only transform
counts grouping, normalization, reconstruction, and line-diff calls with User
Timing marks. Do not mix instrumented results with clean timings. Set
`OPENCODE_PERFORMANCE_TRACE_DIR` for the existing Chrome trace collector, and
`PATCH_SCREENSHOTS=1` for collapsed/expanded screenshots after measurement.
@@ -0,0 +1,143 @@
/// <reference types="vite/client" />
import { render } from "solid-js/web"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { CurrentSessionProviders } from "../../../../session-ui/src/storybook/current-session-story"
import { emptySessionDocument } from "../../../../session-ui/src/storybook/current-session-fixtures"
import { CurrentFileToolGroup, ToolDisplay } from "../../../../session-ui/src/tools/tool-renderer"
import { patchFileGroups } from "../../../../session-ui/src/components/apply-patch-file"
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { createTwoFilesPatch, diffLines } from "diff"
import edit from "../../../../core/src/tool/plugin/edit.ts?raw"
import patch from "../../../../core/src/tool/plugin/patch.ts?raw"
import read from "../../../../core/src/tool/plugin/read.ts?raw"
import shell from "../../../../core/src/tool/plugin/shell.ts?raw"
import "../../../src/index.css"
const scenario = new URLSearchParams(location.search).get("scenario") ?? "complete"
const sources = [edit, patch, read, shell].map((text) => text.replaceAll("\r\n", "\n"))
const names = ["edit", "patch", "read", "shell"]
const changed = (text: string) => text.replaceAll(/\bcontext\b/g, "invocation")
const entry = (index: number, before: string, after: string) => ({
file: `src/tool/plugin/${names[index]}.ts`,
patch: createTwoFilesPatch(names[index], names[index], before, after, "", "", {
context: scenario === "partial" ? 3 : Infinity,
}),
...diffLines(before, after).reduce(
(counts, item) => ({
additions: counts.additions + (item.added ? item.count : 0),
deletions: counts.deletions + (item.removed ? item.count : 0),
}),
{ additions: 0, deletions: 0 },
),
status: "modified" as const,
})
const files =
scenario === "multi"
? sources.map((text, index) => entry(index, text, changed(text)))
: [
entry(0, sources[0], changed(sources[0])),
...(scenario === "chained"
? [entry(0, changed(sources[0]), changed(sources[0]).replaceAll(/\binput\b/g, "parameters"))]
: []),
]
const tools: SessionMessageAssistantTool[] = files.map((file, index) => ({
id: `fixture-edit-${index}`,
type: "tool",
name: "edit",
state: {
status: "completed",
input: { path: file.file, oldString: "context", newString: "invocation", replaceAll: true },
metadata: { files: [file] },
content: [{ type: "text", text: `Edited ${file.file}` }],
},
time: { created: 1, ran: 2, completed: 3 },
}))
declare global {
interface Window {
patchBenchmark: {
payloadBytes: number
sourceBytes: number
files: number
tools: number
grouping: (expanded: boolean) => { ms: number; groups: number; views: number }
}
}
}
window.patchBenchmark = {
payloadBytes: new TextEncoder().encode(JSON.stringify(tools)).length,
sourceBytes: new TextEncoder().encode(sources.slice(0, scenario === "multi" ? 4 : 1).join("")).length,
files: new Set(files.map((file) => file.file)).size,
tools: tools.length,
grouping(expanded) {
const start = performance.now()
const groups = patchFileGroups(files)
const views = expanded ? groups.reduce((count, file) => count + file.views.length, 0) : 0
return { ms: performance.now() - start, groups: groups.length, views }
},
}
function Fixture() {
const [state, setState] = createStore({ mounted: false, duration: 0, rendered: 0 })
let start = 0
return (
<ThemeProvider>
<section style={{ margin: "24px auto", "max-width": "960px" }}>
<button
onClick={() => {
start = performance.now()
setState("mounted", true)
document.querySelector("[data-component=apply-patch-tool]")!.getBoundingClientRect()
setState("duration", performance.now() - start)
}}
>
Mount tools
</button>
<button
onClick={() => {
setState({ mounted: false, rendered: 0 })
}}
>
Unmount tools
</button>
<output data-testid="mount-ms">{state.duration}</output>
<output data-testid="rendered">{state.rendered}</output>
<div
on:click={{
capture: true,
handleEvent() {
start = performance.now()
},
}}
>
<Show when={state.mounted}>
<CurrentSessionProviders document={emptySessionDocument}>
<Show
when={scenario === "direct"}
fallback={
<CurrentFileToolGroup
tools={tools}
onSizeChange={() => setState("rendered", performance.now() - start)}
/>
}
>
<ToolDisplay
id="fixture-patch"
tool="patch"
input={{}}
metadata={{ files }}
status="completed"
onContentRendered={() => setState("rendered", performance.now() - start)}
/>
</Show>
</CurrentSessionProviders>
</Show>
</div>
</section>
</ThemeProvider>
)
}
render(() => <Fixture />, document.getElementById("root")!)
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Patch groups benchmark</title>
</head>
<body>
<main id="root"></main>
<script type="module" src="./fixture.tsx"></script>
</body>
</html>
@@ -0,0 +1,66 @@
import { benchmark, expect } from "../benchmark"
for (const scenario of ["complete", "partial", "chained", "multi", "direct"]) {
benchmark(`patch groups ${scenario}`, async ({ page, report }, info) => {
await page.goto(`/?scenario=${scenario}`)
await expect(page.getByRole("button", { name: "Mount tools", exact: true })).toBeEnabled()
await page.evaluate(() => document.fonts.ready)
expect(await page.evaluate(() => document.fonts.check('13px "Inter"'))).toBe(true)
const shape = await page.evaluate(() => {
const { grouping, ...shape } = window.patchBenchmark
performance.clearMarks()
return shape
})
const mount = async () => {
await page.getByRole("button", { name: "Mount tools", exact: true }).click()
await expect(page.locator('[data-slot="apply-patch-filename"]')).toHaveCount(shape.files)
await expect(page.locator('[data-component="file"]')).toHaveCount(0)
return Number(await page.getByTestId("mount-ms").textContent())
}
const cold = await mount()
const counters = await page.evaluate(() =>
Object.fromEntries(
["patchFileGroups", "normalize", "completePatchContents", "diffLines"].map((name) => [
name,
performance.getEntriesByName(`patch-counter:${name}`).length,
]),
),
)
await page.getByRole("button", { name: "Unmount tools", exact: true }).click()
await expect(page.locator('[data-component="apply-patch-tool"]')).toHaveCount(0)
await page.evaluate(() => performance.clearMarks())
const warm = await mount()
const warmCounters = await page.evaluate(() =>
Object.fromEntries(
["patchFileGroups", "normalize", "completePatchContents", "diffLines"].map((name) => [
name,
performance.getEntriesByName(`patch-counter:${name}`).length,
]),
),
)
const file = page.locator('[data-scope="apply-patch"] button').filter({ hasText: "edit.ts" })
await expect(file).toHaveAttribute("aria-expanded", "false")
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await expect(page.getByTestId("rendered")).not.toHaveText("0")
await expect(page.locator('[data-component="file"]')).toBeVisible()
const expansion = Number(await page.getByTestId("rendered").textContent())
const grouping = await page.evaluate(() => ({
collapsed: window.patchBenchmark.grouping(false),
expanded: window.patchBenchmark.grouping(true),
}))
expect(grouping.collapsed.groups).toBe(shape.files)
report(
{ cold, warm, expansion, grouping, counters, warmCounters },
{ scenario, ...shape, scope: "production tool components" },
)
if (process.env.PATCH_SCREENSHOTS === "1") {
await page.screenshot({ path: info.outputPath(`${scenario}-expanded.png`) })
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "false")
await page
.locator('[data-component="apply-patch-tool"]')
.screenshot({ path: info.outputPath(`${scenario}-collapsed.png`) })
}
})
}
@@ -0,0 +1,18 @@
import { defineConfig } from "@playwright/test"
const baseURL = `http://127.0.0.1:${process.env.PATCH_PORT ?? 4317}`
export default defineConfig({
testDir: ".",
testMatch: "*.bench.ts",
workers: 1,
retries: 0,
timeout: 60_000,
outputDir: process.env.PATCH_RESULTS_DIR,
reporter: "line",
use: { baseURL, viewport: { width: 1366, height: 768 }, colorScheme: "light" },
webServer: {
command: "bun serve.ts",
url: baseURL,
reuseExistingServer: false,
},
})
@@ -0,0 +1,13 @@
import path from "node:path"
const directory = process.env.PATCH_BUILD_DIR
if (!directory) throw new Error("PATCH_BUILD_DIR is required")
Bun.serve({
hostname: "127.0.0.1",
port: Number(process.env.PATCH_PORT ?? 4317),
async fetch(request) {
const pathname = new URL(request.url).pathname
const file = Bun.file(path.join(directory, pathname === "/" ? "index.html" : pathname))
return (await file.exists()) ? new Response(file) : new Response("Not found", { status: 404 })
},
})
@@ -0,0 +1,51 @@
import { defineConfig } from "vite"
import solid from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "node:url"
import { execFileSync } from "node:child_process"
import path from "node:path"
export default defineConfig({
root: fileURLToPath(new URL(".", import.meta.url)),
publicDir: fileURLToPath(new URL("../../../public", import.meta.url)),
plugins: [
solid(),
tailwindcss(),
{
name: "patch-group-counters",
enforce: "pre",
load(id) {
if (!process.env.PATCH_REVISION) return
const root = fileURLToPath(new URL("../../../../..", import.meta.url))
const file = path.relative(root, id).replaceAll("\\", "/")
if (
![
"packages/session-ui/src/components/apply-patch-file.ts",
"packages/session-ui/src/tools/tool-renderer.tsx",
].includes(file)
)
return
return execFileSync("git", ["show", `${process.env.PATCH_REVISION}:${file}`], { cwd: root, encoding: "utf8" })
},
transform(code, id) {
if (process.env.PATCH_COUNTERS !== "1") return
const functions = id.replaceAll("\\", "/").endsWith("/apply-patch-file.ts")
? ["patchFileGroups"]
: id.replaceAll("\\", "/").endsWith("/session-diff.ts")
? ["normalize", "completePatchContents"]
: id.replaceAll("\\", "/").endsWith("/diff/line.js")
? ["diffLines"]
: []
for (const name of functions) {
const pattern = new RegExp(`(export function ${name}\\([^)]*\\)[^{]*\\{)`)
if (!pattern.test(code)) throw new Error(`Missing instrumented function ${name} in ${id}`)
code = code.replace(pattern, `$1 performance.mark("patch-counter:${name}");`)
}
return functions.length ? { code, map: null } : undefined
},
},
],
resolve: { dedupe: ["solid-js", "@solidjs/meta"] },
worker: { format: "es" },
build: { outDir: process.env.PATCH_BUILD_DIR, emptyOutDir: true, sourcemap: true },
})
@@ -0,0 +1,315 @@
import type { CDPSession, Page } from "@playwright/test"
import { benchmark, expect } from "../benchmark"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { APP_READY_TIMEOUT } from "../../utils/waits"
import { fixture as stress } from "./session-timeline-stress.fixture"
import { createHomeIndexFixture, type HomeIndexFixture } from "./home-session-index.fixture"
// Home fetches the root-session index on mount. These cases hold the visible
// output constant (newest 64 rows, same order) while the index size grows, so
// bytes, main-thread work, time to actionable rows, and retained heap can be
// attributed to index handling rather than to what the user sees.
const sizes = (process.env.HOME_INDEX_SIZES ?? "500,5000,10000").split(",").map(Number)
const churnSize = Number(process.env.HOME_INDEX_CHURN_SIZE ?? 10_000)
const updates = Number(process.env.HOME_INDEX_UPDATES ?? 20)
// Forced GC changes timing; retention runs stay separate from clean timing runs.
const memory = process.env.OPENCODE_PERFORMANCE_MEMORY === "1"
const rowContainer = '[data-component="home-session-row-container"]'
const row = '[data-component="home-session-row"]'
type Probe = {
expected: number
rows?: number
frame?: number
pending: Record<string, string>
titles: Record<string, number>
}
type ProbeWindow = Window & {
__homeIndexProbe?: Probe
__mockServerStream?: { push: (payloads: unknown[]) => void }
}
// Interaction-scoped tracing keeps the page-lifetime Chrome trace off unless a
// scenario starts one; service workers stay out of the renderer measurement.
benchmark.use({
viewport: { width: 1440, height: 900 },
video: "off",
trace: "off",
serviceWorkers: "block",
traceScope: "interaction",
})
benchmark.describe("performance: home session index", () => {
for (const count of sizes) {
benchmark(`loads home with ${count} root sessions`, async ({ page, report }, testInfo) => {
benchmark.setTimeout(180_000)
const fixture = createHomeIndexFixture({ count, now: Date.now() })
const network = await setup(page, fixture)
const cdp = await page.context().newCDPSession(page)
await cdp.send("Performance.enable")
await page.goto("/")
const rows = page.locator(row)
await expect(rows).toHaveCount(fixture.expected.visible, { timeout: APP_READY_TIMEOUT })
const first = page.locator(rowContainer).filter({ hasText: fixture.expected.newestTitle })
await expect(first).toHaveAttribute("data-session-id", fixture.expected.newestID)
await expect(first.locator(row)).toBeEnabled()
// Row order is part of the held-constant output: the DOM must list the
// newest session first.
await expect(page.locator(rowContainer).nth(0)).toHaveAttribute("data-session-id", fixture.expected.newestID)
const probe = await readProbe(page)
const metrics = await performanceMetrics(cdp)
const retained = memory ? await retainedHeap(cdp) : undefined
await network.settle()
if (testInfo.repeatEachIndex === 0) {
const path = testInfo.outputPath(`home-${count}.png`)
await page.screenshot({ path })
await testInfo.attach(`home-${count}`, { path, contentType: "image/png" })
}
report(
{
listRequests: network.list.requests,
listBytes: network.list.bytes,
rowsMs: probe.rows,
frameMs: probe.frame,
listEndMs: probe.listEnd,
processMs: probe.rows - probe.listEnd,
// ThreadTime is main-thread CPU time; ScriptDuration only covers
// Blink-invoked callbacks, so promise continuations are missing from it.
threadMs: metrics.ThreadTime * 1000,
scriptMs: metrics.ScriptDuration * 1000,
taskMs: metrics.TaskDuration * 1000,
layoutMs: metrics.LayoutDuration * 1000,
styleMs: metrics.RecalcStyleDuration * 1000,
heapUsedMB: metrics.JSHeapUsedSize / 1_048_576,
heapTotalMB: metrics.JSHeapTotalSize / 1_048_576,
nodes: metrics.Nodes,
...(retained ? { retainedHeapMB: retained.usedSize / 1_048_576, retainedNodes: retained.nodes } : {}),
},
{
sessions: count,
directories: fixture.directories.length,
fixtureVersion: fixture.version,
fixtureListBytes: fixture.listBytes,
visibleRows: fixture.expected.visible,
gc: memory ? "explicit" : "none",
scope: "renderer main isolate; not total desktop RAM",
},
)
expect(probe.rows).toBeGreaterThan(0)
await cdp.detach()
})
}
benchmark(
`applies ${updates} background session updates on home with ${churnSize} root sessions`,
async ({ page, report }) => {
benchmark.setTimeout(180_000)
const fixture = createHomeIndexFixture({ count: churnSize, now: Date.now() })
const target = fixture.sessions[fixture.sessions.length - 1]
const network = await setup(page, fixture)
const cdp = await page.context().newCDPSession(page)
await cdp.send("Performance.enable")
// Home prefetches the two newest sessions, which makes them locally known
// and therefore part of every later index merge, like an open session.
const prefetch = page.waitForResponse(
(response) => response.request().method() === "GET" && response.url().includes(`/api/session/${target.id}`),
)
await page.goto("/")
await expect(page.locator(row)).toHaveCount(fixture.expected.visible, { timeout: APP_READY_TIMEOUT })
await prefetch
const titleLocator = page.locator(
`${rowContainer}[data-session-id="${target.id}"] [data-component="home-session-title"]`,
)
await expect(titleLocator).toHaveText(fixture.expected.newestTitle)
const before = await performanceMetrics(cdp)
const samples: number[] = []
for (let index = 1; index <= updates; index++) {
const title = `${fixture.expected.newestTitle} · update ${index}`
// The completed run bumps the session's updated time and title on the
// server; the client re-reads the session and re-merges the index.
target.title = title
target.time.updated += 1000
target.time.idle = target.time.updated
const pushed = await page.evaluate(
({ id, title, event }) => {
const host = window as ProbeWindow
if (!host.__homeIndexProbe || !host.__mockServerStream) throw new Error("Missing Home index probe")
host.__homeIndexProbe.pending[id] = title
host.__mockServerStream.push([event])
return performance.now()
},
{
id: target.id,
title,
event: {
id: `evt_home_update_${index}`,
created: Date.now(),
type: "session.execution.succeeded",
data: { sessionID: target.id },
},
},
)
await expect(titleLocator).toHaveText(title)
const seen = await page.evaluate(({ title }) => (window as ProbeWindow).__homeIndexProbe?.titles[title], {
title,
})
if (seen === undefined) throw new Error(`Probe did not observe title: ${title}`)
samples.push(seen - pushed)
}
const after = await performanceMetrics(cdp)
await network.settle()
const sorted = samples.toSorted((a, b) => a - b)
report(
{
updates,
updateMs: sorted,
updateMedianMs: median(sorted),
updateP95Ms: sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)],
threadMs: (after.ThreadTime - before.ThreadTime) * 1000,
scriptMs: (after.ScriptDuration - before.ScriptDuration) * 1000,
taskMs: (after.TaskDuration - before.TaskDuration) * 1000,
layoutMs: (after.LayoutDuration - before.LayoutDuration) * 1000,
sessionReads: network.get.requests,
},
{
sessions: churnSize,
directories: fixture.directories.length,
fixtureVersion: fixture.version,
event: "session.execution.succeeded",
scope: "renderer main isolate; latency from event push to row title update",
},
)
expect(samples).toHaveLength(updates)
await cdp.detach()
},
)
})
async function setup(page: Page, fixture: HomeIndexFixture) {
const primary = fixture.directories[0]
await mockOpenCodeServer(page, {
directory: primary.directory,
project: {
id: primary.projectID,
worktree: primary.directory,
vcs: "git",
name: primary.name,
time: { created: fixture.now - 400 * 86_400_000, updated: fixture.now },
sandboxes: [],
},
sessions: fixture.sessions,
pageMessages: () => ({ items: [] }),
provider: stress.provider,
})
await page.addInitScript(
({ projects }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: projects.map((worktree, index) => ({ worktree, expanded: index === 0 })) },
lastProject: { local: projects[0] },
}),
)
},
{ projects: fixture.directories.filter((entry) => entry.project).map((entry) => entry.directory) },
)
await page.addInitScript(
({ expected }) => {
const host = window as ProbeWindow
const probe: Probe = { expected, pending: {}, titles: {} }
host.__homeIndexProbe = probe
const observer = new MutationObserver(() => {
if (probe.rows === undefined) {
const count = document.querySelectorAll('[data-component="home-session-row"]').length
if (count >= probe.expected) {
probe.rows = performance.now()
requestAnimationFrame((time) => {
probe.frame = time
})
}
}
for (const [id, title] of Object.entries(probe.pending)) {
const element = document.querySelector(
`[data-component="home-session-row-container"][data-session-id="${id}"] [data-component="home-session-title"]`,
)
if (element?.textContent !== title) continue
probe.titles[title] = performance.now()
delete probe.pending[id]
}
})
// Init scripts run before <html> exists; the document node itself is always observable.
observer.observe(document, { childList: true, subtree: true, characterData: true })
},
{ expected: fixture.expected.visible },
)
const list = { requests: 0, bytes: 0 }
const get = { requests: 0, bytes: 0 }
const pending: Promise<void>[] = []
page.on("response", (response) => {
const request = response.request()
if (request.method() !== "GET") return
const url = new URL(response.url())
const isList = url.pathname === "/api/session"
const isGet = /^\/api\/session\/[^/]+$/.test(url.pathname)
if (!isList && !isGet) return
const bucket = isList ? list : get
bucket.requests += 1
pending.push(
response
.body()
.then((body) => {
bucket.bytes += body.byteLength
})
.catch(() => {}),
)
})
return {
list,
get,
settle: () => Promise.all(pending).then(() => {}),
}
}
async function readProbe(page: Page) {
const probe = await page.evaluate(() => {
const host = window as ProbeWindow
if (!host.__homeIndexProbe) throw new Error("Missing Home index probe")
// Resource timing marks when the last index page finished arriving, so
// rows - listEnd isolates parse, merge, and render from transfer and boot.
const listEnd = Math.max(
0,
...performance
.getEntriesByType("resource")
.filter((entry) => new URL(entry.name).pathname === "/api/session")
.map((entry) => (entry as PerformanceResourceTiming).responseEnd),
)
return { rows: host.__homeIndexProbe.rows, frame: host.__homeIndexProbe.frame, listEnd }
})
if (probe.rows === undefined) throw new Error("Probe did not observe the expected Home rows")
return { rows: probe.rows, frame: probe.frame, listEnd: probe.listEnd }
}
async function performanceMetrics(cdp: CDPSession) {
const result = await cdp.send("Performance.getMetrics")
return Object.fromEntries(result.metrics.map((metric) => [metric.name, metric.value])) as Record<string, number>
}
async function retainedHeap(cdp: CDPSession) {
// GC is an explicit retained-heap measurement, not an application optimization or readiness wait.
await cdp.send("HeapProfiler.collectGarbage")
const heap = await cdp.send("Runtime.getHeapUsage")
const dom = await cdp.send("Memory.getDOMCounters")
return { usedSize: heap.usedSize, nodes: dom.nodes }
}
function median(sorted: number[]) {
if (sorted.length === 0) return undefined
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
@@ -0,0 +1,234 @@
import { currentSession } from "../../utils/mock-server"
export const HOME_INDEX_FIXTURE_VERSION = 1
// Home shows the newest 64 root sessions; the fixture asserts that many rows.
export const HOME_INDEX_VISIBLE_LIMIT = 64
export type HomeIndexSession = {
id: string
projectID: string
title?: string
agent: string
model: { id: string; providerID: string; variant: string }
cost: number
tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
outcome: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle: number; viewed?: number }
location: { directory: string }
}
export type HomeIndexDirectory = {
directory: string
name: string
projectID: string
// Local project entries appear in the Home project list; the rest model
// sessions whose project was removed from the sidebar.
project: boolean
}
const repos = [
"opencode",
"storefront-api",
"billing-worker",
"design-system",
"mobile-app",
"infra-terraform",
"docs-site",
"analytics-pipeline",
"auth-service",
"legacy-admin",
"notebooks",
"dotfiles",
]
const verbs = [
"Fix",
"Investigate",
"Refactor",
"Add",
"Remove",
"Debug",
"Migrate",
"Implement",
"Review",
"Optimize",
"Document",
"Rename",
"Extract",
"Wire up",
"Stabilize",
]
const objects = [
"flaky retry in the session runner",
"memory growth in the Home index",
"i18n keys for the settings dialog",
"the review pane remount on tab switch",
"SQLite migration for session inbox",
"OAuth callback handling",
"terminal scrollback serialization",
"Playwright visual stability probes",
"the composer paste path",
"provider catalog normalization",
"the worktree preparation flow",
"CI cache keys for bun install",
"Markdown highlighting for large fences",
"the permission auto-approver",
"cursor pagination for /api/session",
"the desktop titlebar on Windows",
"the file tree lazy loading",
"event replay ordering",
"RTL layout in the sidebar",
"unread badges for background sessions",
]
const contexts = [
"",
"",
"",
" (#{n})",
" in packages/app",
" in packages/core",
" for v2",
" before release",
" — follow-up",
" · src/{file}.ts",
" after the Electron upgrade",
" with tests",
]
const files = ["controller", "index", "records", "store", "runtime", "layout", "timeline", "composer", "data", "sync"]
const agents = ["build", "build", "build", "build", "plan", "general"]
const models = [
{ id: "claude-opus-4-6", providerID: "anthropic", variant: "default" },
{ id: "claude-sonnet-4-6", providerID: "anthropic", variant: "default" },
{ id: "gpt-5.3-codex", providerID: "openai", variant: "high" },
{ id: "gemini-3-pro", providerID: "google", variant: "default" },
]
const DAY = 24 * 60 * 60 * 1000
export function createHomeIndexFixture(input: { count: number; now: number; directories?: number }) {
const random = mulberry32(0x5eed_0000 + input.count)
const directoryCount = Math.min(input.directories ?? 12, repos.length)
const directories: HomeIndexDirectory[] = repos.slice(0, directoryCount).map((name, index) => ({
directory: `/Users/dev/repos/${name}`,
name,
projectID: `prj_${hex(random, 16)}`,
project: index < Math.max(1, Math.round(directoryCount * 0.66)),
}))
// Zipf-like spread: a few repositories hold most of the history.
const weights = directories.map((_, index) => 1 / Math.pow(index + 1, 0.9))
const total = weights.reduce((sum, weight) => sum + weight, 0)
const cumulative = weights.map((_, index) => weights.slice(0, index + 1).reduce((sum, w) => sum + w, 0) / total)
// Newest first; adding the index after sorting keeps offsets strictly
// increasing so no two sessions share an updated time.
const offsets = Array.from({ length: input.count }, () => {
const bucket = random()
// 5% today, 5% yesterday, the rest skewed toward recent months over 18 months.
if (bucket < 0.05) return Math.floor(random() * DAY * 0.9)
if (bucket < 0.1) return DAY + Math.floor(random() * DAY * 0.9)
return 2 * DAY + Math.floor(Math.pow(random(), 2) * 538 * DAY)
})
.sort((a, b) => a - b)
.map((offset, index) => offset + index)
const newestFirst: HomeIndexSession[] = offsets.map((offset, index) => {
const pick = random()
const directory = directories[cumulative.findIndex((edge) => pick <= edge)] ?? directories[0]
const updated = input.now - offset
const duration = 5 * 60_000 + Math.floor(random() * 6 * 60 * 60_000)
const tokens = {
input: 5_000 + Math.floor(random() * 400_000),
output: 500 + Math.floor(random() * 60_000),
reasoning: random() < 0.6 ? Math.floor(random() * 20_000) : 0,
cache: { read: Math.floor(random() * 900_000), write: Math.floor(random() * 120_000) },
}
const outcome = random() < 0.9 ? "succeeded" : random() < 0.6 ? "failed" : "interrupted"
return {
id: `ses_${base62(random, 26)}`,
projectID: directory.projectID,
...(random() < 0.97 ? { title: title(random, index) } : {}),
agent: agents[Math.floor(random() * agents.length)],
model: models[Math.floor(random() * models.length)],
// USD at $3/M input, $15/M output, $0.30/M cache read, $3.75/M cache write.
cost:
Math.round(
(tokens.input * 3 + tokens.output * 15 + tokens.cache.read * 0.3 + tokens.cache.write * 3.75) / 100,
) / 10_000,
tokens,
outcome,
time: {
created: updated - duration,
updated,
idle: updated - Math.floor(random() * 2_000),
...(random() < 0.8 ? { viewed: updated } : {}),
},
location: { directory: directory.directory },
}
})
// The mock lists sessions in array order and reverses for `desc`, so keep
// the fixture ascending by updated time like the server's index order.
const sessions = newestFirst.toReversed()
const newest = newestFirst[0]
const encoded = JSON.stringify({ data: sessions.map((session) => currentSession(session)), cursor: {} })
return {
version: HOME_INDEX_FIXTURE_VERSION,
count: input.count,
now: input.now,
directories,
sessions,
// Bytes the mock serves for the complete index when it fits one page.
listBytes: Buffer.byteLength(encoded),
expected: {
visible: Math.min(HOME_INDEX_VISIBLE_LIMIT, input.count),
newestID: newest.id,
// The mock labels untitled sessions with their ID.
newestTitle: newest.title ?? newest.id,
perDirectory: Object.fromEntries(
[...Map.groupBy(sessions, (session) => session.location.directory)].map(([directory, items]) => [
directory,
items.length,
]),
),
},
}
}
export type HomeIndexFixture = ReturnType<typeof createHomeIndexFixture>
function title(random: () => number, index: number) {
const verb = verbs[Math.floor(random() * verbs.length)]
const object = objects[Math.floor(random() * objects.length)]
const context = contexts[Math.floor(random() * contexts.length)]
.replace("{n}", String(1000 + Math.floor(random() * 45_000)))
.replace("{file}", files[Math.floor(random() * files.length)])
// Keep titles unique so row identity checks cannot match a sibling.
return `${verb} ${object}${context} [${index.toString(36)}]`
}
function mulberry32(seed: number) {
let state = seed >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
function base62(random: () => number, length: number) {
return Array.from({ length }, () => alphabet[Math.floor(random() * alphabet.length)]).join("")
}
function hex(random: () => number, length: number) {
return Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join("")
}
@@ -0,0 +1,15 @@
import config from "../playwright.config"
export default {
...config,
testDir: ".",
testMatch: "timeline-projection-benchmark.spec.ts",
outputDir: process.env.PROJECTION_OUTPUT,
webServer: {
...config.webServer,
command: `bun run serve -- --host 127.0.0.1 --port ${process.env.PLAYWRIGHT_PORT ?? 3000} --strictPort --outDir ${process.env.PROJECTION_BUNDLE ?? "dist"}`,
url: `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? 3000}`,
reuseExistingServer: false,
},
use: { ...config.use, video: "off" as const, trace: "off" as const },
}
@@ -0,0 +1,43 @@
import { defineConfig, mergeConfig } from "vite"
import config from "../../../vite.config"
// Benchmark-only instrumentation. Normal production builds contain no probes.
export default mergeConfig(
config,
defineConfig({
plugins: [
{
name: "timeline-projection-measurement",
enforce: "pre",
transform(source, id) {
if (!id.replaceAll("\\", "/").endsWith("/session-ui/src/timeline/projection.ts")) return
const start = " type Turn = {"
const end = "\n export function constructMessageRows("
if (!source.includes(start) || !source.includes(end)) throw new Error("Projection probe boundary changed")
return source
.replace(
start,
`
const probe = globalThis.__timelineProjectionProbe
const started = probe ? performance.now() : 0
try {
${start}`,
)
.replace(
end,
`
finally {
if (probe) {
probe.calls += 1
probe.entries += messages.length
probe.ms += performance.now() - started
}
}
}
${end}`,
)
},
},
],
}),
)
@@ -39,13 +39,7 @@ const editPart: ToolSeed = {
content: [{ type: "text", text: "Edited src/regression.ts" }],
metadata: {
files: [
currentFile(
"src/regression.ts",
"export const value = 'before'\n",
"export const value = 'after'\n",
1,
1,
),
currentFile("src/regression.ts", "export const value = 'before'\n", "export const value = 'after'\n", 1, 1),
],
},
},
@@ -70,6 +64,8 @@ export async function setupTimelineBenchmark(
eventBatch: number
vcsDiff?: unknown[]
turnDiffs?: unknown[]
busy?: boolean
historyShape?: "mixed" | "tool-heavy"
},
) {
const events: EventPayload[] = []
@@ -77,19 +73,47 @@ export async function setupTimelineBenchmark(
const currentUserMessage = options.turnDiffs
? { ...userMessage, metadata: { diffs: options.turnDiffs as JsonValue } }
: userMessage
const messages = [
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index))
.flat()
.map((message) => {
if (options.historyShape !== "tool-heavy" || message.type !== "assistant") return message
return {
...message,
content: [
...Array.from({ length: 6 }, (_, index) =>
toolContent({
id: `${message.id}:read:${index}`,
type: "tool",
name: "read",
state: {
status: "completed",
input: { path: `src/session/module-${index}.ts` },
content: [{ type: "text", text: historicalSource(index, false) }],
metadata: {},
},
time: {
created: message.time.created,
ran: message.time.created,
completed: message.time.created + 100,
},
}),
),
...message.content,
],
}
}),
currentUserMessage,
assistantMessage,
]
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
vcsDiff: options.vcsDiff,
pageMessages: () => ({
items: [
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
currentUserMessage,
assistantMessage,
],
}),
sessionStatus: options.busy ? { [sessionID]: { type: "busy" } } : undefined,
pageMessages: () => ({ items: messages }),
events: () => events.splice(0, eventBatch),
eventRetry: 16,
})
@@ -113,6 +137,11 @@ export async function setupTimelineBenchmark(
await expectSessionTitle(page, title)
await expectAppVisible(scroller)
return {
workload: {
messages: messages.length,
parts: messages.reduce((sum, message) => sum + (message.type === "assistant" ? message.content.length : 0), 0),
historyBytes: Buffer.byteLength(JSON.stringify(messages)),
},
scroller,
text,
transport: {
@@ -0,0 +1,169 @@
import { createServer, type ServerResponse } from "node:http"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
import {
buildInitialStreamEvent,
buildStreamDeltaEvents,
setupTimelineBenchmark,
textPartID,
} from "./session-timeline-benchmark.fixture"
type Probe = { calls: number; entries: number; ms: number }
type Measurement = { frames: number[]; started: number; ready: number; rowReplacements: number; stop: () => void }
declare global {
interface Window {
__timelineProjectionProbe?: Probe
__projectionMeasurement: Measurement
}
}
benchmark.use({ traceScope: "interaction" })
for (const scenario of [
{ historyTurns: 40, historyShape: "mixed" },
{ historyTurns: 320, historyShape: "mixed" },
{ historyTurns: 320, historyShape: "tool-heavy" },
] as const) {
benchmark(`text projection ${scenario.historyTurns} ${scenario.historyShape}`, async ({ page, report }) => {
benchmark.setTimeout(120_000)
const responses = new Set<ServerResponse>()
const source = createServer((request, response) => {
response.writeHead(200, {
"content-type": "text/event-stream",
"access-control-allow-origin": "*",
"cache-control": "no-cache",
})
response.write(
`data: ${JSON.stringify({ id: "evt_projection_connected", type: "server.connected", data: {} })}\n\n`,
)
responses.add(response)
request.on("close", () => responses.delete(response))
})
await new Promise<void>((resolve) => source.listen(0, "127.0.0.1", resolve))
const address = source.address()
if (!address || typeof address === "string") throw new Error("Missing fixture SSE address")
let timer: ReturnType<typeof setInterval> | undefined
const send = (events: OpenCodeEvent[]) =>
responses.forEach((response) => events.forEach((event) => response.write(`data: ${JSON.stringify(event)}\n\n`)))
try {
await page.addInitScript(
({ url, counters }) => {
Object.assign(window, { __testSseTransport: true })
if (counters) window.__timelineProjectionProbe = { calls: 0, entries: 0, ms: 0 }
const fetch = window.fetch.bind(window)
const intercept = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
return fetch(
new URL(request.url).pathname === "/api/event" ? new Request(url, { signal: request.signal }) : request,
)
}
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: intercept })
},
{ url: `http://127.0.0.1:${address.port}`, counters: process.env.PROJECTION_COUNTERS === "1" },
)
const initialStarted = performance.now()
const fixture = await setupTimelineBenchmark(page, { ...scenario, busy: true, eventBatch: 1 })
await expect.poll(() => responses.size).toBe(1)
const deltas = buildStreamDeltaEvents(160)
send(buildInitialStreamEvent(160))
await expect(fixture.text).toContainText("Implementation plan")
await expect(fixture.text.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
const initialReadyMs = performance.now() - initialStarted
await fixture.scrollToBottom()
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
await benchmarkDiagnostics(page).startTrace()
await page.evaluate(
({ partID, counters }) => {
const part = document.querySelector(`[data-timeline-part-id="${partID}"]`)
const row = part?.closest("[data-timeline-key]")
if (!row) throw new Error("Missing active row")
if (counters) {
if (!window.__timelineProjectionProbe?.calls)
throw new Error("Projection instrumentation did not observe initial construction")
window.__timelineProjectionProbe = { calls: 0, entries: 0, ms: 0 }
}
const measurement: Measurement = {
frames: [],
started: performance.now(),
ready: 0,
rowReplacements: 0,
stop: () => {},
}
window.__projectionMeasurement = measurement
let previous: number | undefined
let frame = 0
let current = row
const sample = (now: number) => {
if (previous !== undefined) measurement.frames.push(now - previous)
previous = now
const next = document.querySelector(`[data-timeline-part-id="${partID}"]`)?.closest("[data-timeline-key]")
if (next && next !== current) {
measurement.rowReplacements++
current = next
}
const markdown = next?.querySelector('[data-component="markdown"][data-markdown-ready]')
if (markdown?.textContent?.includes("benchmark-complete")) {
measurement.ready = performance.now() - measurement.started
return
}
frame = requestAnimationFrame(sample)
}
measurement.stop = () => cancelAnimationFrame(frame)
frame = requestAnimationFrame(sample)
},
{ partID: textPartID, counters: process.env.PROJECTION_COUNTERS === "1" },
)
const emitted: number[] = []
const started = performance.now()
// The source clock runs in Node, never waiting for a renderer acknowledgement.
await new Promise<void>((resolve) => {
timer = setInterval(() => {
const event = deltas[emitted.length]
if (!event) throw new Error("Unexpected source overrun")
send([event])
emitted.push(performance.now() - started)
if (emitted.length !== deltas.length) return
clearInterval(timer)
resolve()
}, 25)
})
await expect(fixture.text).toContainText("benchmark-complete")
await expect(fixture.text.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await page.waitForFunction(() => window.__projectionMeasurement.ready > 0)
const metrics = await page.evaluate(() => {
const data = window.__projectionMeasurement
data.stop()
return {
frames: data.frames,
readyMs: data.ready,
rowReplacements: data.rowReplacements,
projection: window.__timelineProjectionProbe ?? null,
}
})
expect(emitted).toHaveLength(160)
expect(metrics.frames.length).toBeGreaterThan(0)
await benchmarkDiagnostics(page).stop()
report(
{ ...metrics, initialReadyMs, emitted },
{
...scenario,
...fixture.workload,
deltaBytes: Buffer.byteLength(JSON.stringify(deltas)),
counters: process.env.PROJECTION_COUNTERS === "1",
intervalMs: 25,
deltas: 160,
viewport: "1366x768",
revision: process.env.PROJECTION_REVISION,
},
)
if (process.env.PROJECTION_SCREENSHOT)
await page.screenshot({
path: `${process.env.PROJECTION_SCREENSHOT}-${scenario.historyTurns}-${scenario.historyShape}.png`,
})
} finally {
clearInterval(timer)
source.closeAllConnections()
await new Promise<void>((resolve, reject) => source.close((error) => (error ? reject(error) : resolve())))
}
})
}
@@ -0,0 +1,32 @@
# Timeline Text Projection Benchmark
Run from `packages/app`, against a production bundle. No OpenCode server is needed.
The fixture owns its HTTP event source and mocks all other API responses.
```sh
bun run build -- --config e2e/performance/timeline/projection.vite.config.ts
PLAYWRIGHT_BUILD=1 playwright test --config e2e/performance/timeline/projection.playwright.config.ts --repeat-each=20 --workers=1 --retries=0 --reporter=line
```
Set `PLAYWRIGHT_PORT` to an unused local port. Set `PROJECTION_BUNDLE` to preview a
previously frozen bundle, `PROJECTION_OUTPUT` for test artifacts, and
`PROJECTION_REVISION` to label results. Keep all three workloads separate:
40 historical user/assistant pairs, 320 pairs, and 320 pairs with six additional
completed read tools per assistant. The existing mixed fixture includes Markdown,
reasoning, and edit/write/patch output. Results include serialized history bytes,
message/part counts, and event bytes.
Each case opens the real application timeline with a busy session, waits for the
initial Markdown to be ready, then sends the same 160 text deltas at a 25 ms source
cadence. The Node HTTP source does not wait for renderer acknowledgements. Results
retain every source emission time and animation-frame interval. Completion means
the final marker is in ready Markdown and has reached an animation-frame callback;
it does not claim a compositor presentation timestamp. Row replacements count the
active streaming row, not normal virtualizer mounts while scrolling.
Use `PROJECTION_COUNTERS=1` for separate diagnostic runs. The benchmark build
instruments the full-history constructor with its call count, input entries visited,
and synchronous elapsed time. The probe is absent from normal builds and disabled
in clean timing runs. `OPENCODE_PERFORMANCE_TRACE_DIR` enables the existing Chrome
trace collector. Set `PROJECTION_SCREENSHOT` to an output path prefix for screenshots
after timing. Do not compare diagnostic timing with clean timing.
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test"
import { createHomeIndexFixture, HOME_INDEX_VISIBLE_LIMIT } from "../timeline/home-session-index.fixture"
const now = 1_800_000_000_000
test("generates a deterministic index ordered like the server", () => {
const fixture = createHomeIndexFixture({ count: 2_000, now })
const again = createHomeIndexFixture({ count: 2_000, now })
expect(again.sessions).toEqual(fixture.sessions)
expect(fixture.sessions).toHaveLength(2_000)
expect(new Set(fixture.sessions.map((session) => session.id)).size).toBe(2_000)
const updated = fixture.sessions.map((session) => session.time.updated)
expect(updated.every((time, index) => index === 0 || time > updated[index - 1])).toBe(true)
expect(updated.every((time) => time <= now)).toBe(true)
expect(fixture.sessions.every((session) => session.time.created < session.time.updated)).toBe(true)
expect(fixture.expected.newestID).toBe(fixture.sessions[fixture.sessions.length - 1].id)
expect(fixture.expected.visible).toBe(HOME_INDEX_VISIBLE_LIMIT)
})
test("spreads sessions across several directories with a skewed head", () => {
const fixture = createHomeIndexFixture({ count: 5_000, now })
const counts = Object.values(fixture.expected.perDirectory)
expect(counts.reduce((sum, count) => sum + count, 0)).toBe(5_000)
expect(fixture.directories).toHaveLength(12)
expect(fixture.directories.filter((entry) => entry.project)).toHaveLength(8)
const largest = Math.max(...counts)
expect(largest).toBeGreaterThan(5_000 * 0.15)
expect(Math.min(...counts)).toBeGreaterThan(0)
expect(fixture.sessions.some((session) => session.time.updated > now - 86_400_000)).toBe(true)
// Realistic serialized rows: hundreds of bytes each, not one-character stubs.
expect(fixture.listBytes / 5_000).toBeGreaterThan(300)
})
@@ -42,7 +42,7 @@ test("mobile project selection and drawer navigation preserve session identity",
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveText([fixture.project.name, fixture.project.name])
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveCount(0)
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
const help = drawer.getByRole("button", { name: "Help", exact: true })
await expect(settings).toBeVisible()
@@ -149,7 +149,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
({ server, sessionA, sessionB }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
JSON.stringify({ appearance: { tabLayout: "vertical", showProjectName: true }, general: { showStatus: true } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
@@ -219,7 +219,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabB).toBeVisible()
})
test("appearance experimental setting switches tab orientation", async ({ page }) => {
test("appearance experimental settings control vertical tab details", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA }) => {
@@ -251,6 +251,12 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await expect(layout).toContainText("Vertical")
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
const projectNames = page.locator('[data-slot="vertical-tabs-sidebar"] [data-slot="tab-project"]')
await expect(projectNames).toHaveCount(0)
const projectNameSwitch = settings.getByRole("switch", { name: "Show project names", exact: true })
await settings.locator('[data-action="settings-show-project-name"] [data-slot="switch-control"]').click()
await expect(projectNameSwitch).toBeChecked()
await expect(projectNames).toHaveText(["tab-project"])
await expect(settings.getByRole("tablist")).toHaveCSS("width", "240px")
await page.setViewportSize({ width: 920, height: 720 })
@@ -274,6 +280,9 @@ test("appearance experimental setting switches tab orientation", async ({ page }
await page.reload()
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText([
"tab-project",
])
await expect(
page
.locator('[data-slot="mobile-tabs-drawer"]')
+3
View File
@@ -609,9 +609,12 @@ export function currentSession(session: { id: string } & Record<string, unknown>
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...(typeof session.outcome === "string" ? { outcome: session.outcome } : {}),
time: {
created: "created" in time && typeof time.created === "number" ? time.created : 0,
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
...("idle" in time && typeof time.idle === "number" ? { idle: time.idle } : {}),
...("viewed" in time && typeof time.viewed === "number" ? { viewed: time.viewed } : {}),
...(session.time && typeof session.time === "object" && "archived" in session.time
? { archived: session.time.archived }
: {}),
@@ -7,7 +7,12 @@ import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
import {
HOME_SESSION_LIMIT,
loadHomeSessionIndex,
mergeHomeSessionIndex,
retainHomeSessions,
} from "@/home/sessions/index"
import type { LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
@@ -25,8 +30,7 @@ import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
export type { HomeSessionRecord } from "./records"
const HOME_SESSION_LIMIT = 64
// Keep the large immutable result opaque so Solid Query does not recursively unwrap every session on mount.
// Keep the immutable result opaque so Solid Query does not recursively unwrap every session on mount.
const selectSessions = (sessions: SessionInfo[]) => () => sessions
export type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
+116 -7
View File
@@ -1,6 +1,15 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { HOME_V2_SESSION_PAGE_LIMIT, loadHomeSessionIndex, parseHomeSessionIndex, retainHomeSessions } from "./index"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "@/runtime/server/global-sync/types"
import {
HOME_SESSION_INDEX_LIMIT,
HOME_SESSION_LIMIT,
HOME_V2_SESSION_PAGE_LIMIT,
loadHomeSessionIndex,
mergeHomeSessionIndex,
parseHomeSessionIndex,
retainHomeSessions,
} from "./index"
const session = (id: string, input: Partial<SessionInfo> = {}) =>
({
@@ -12,19 +21,65 @@ const session = (id: string, input: Partial<SessionInfo> = {}) =>
...input,
}) as SessionInfo
// The loader anchors its recent window on the wall clock, so fixtures do too.
const now = Date.now()
const minute = 60_000
// One session per minute going back from `now`; index 0 is the newest, which
// is the server's list order.
const history = (directory: string, count: number) =>
Array.from({ length: count }, (_, index) =>
session(`${directory}-${String(index).padStart(5, "0")}`, {
time: { created: now - (index + 1) * minute - 1000, updated: now - (index + 1) * minute },
location: { directory },
}),
)
const ids = (sessions: SessionInfo[]) => sessions.map((item) => item.id)
describe("Home session index", () => {
test("loads all pages", async () => {
const first = Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session(`session-${index}`))
test("follows cursors across pages and bounds the result per directory", async () => {
const all = history("/repo", HOME_V2_SESSION_PAGE_LIMIT + 1)
const calls: Array<{ cursor?: string; parentID: null }> = []
const result = await loadHomeSessionIndex(async (input) => {
calls.push(input)
if (!input.cursor) return { data: first, cursor: { next: "next" } }
return { data: [session("last")], cursor: {} }
if (!input.cursor) return { data: all.slice(0, HOME_V2_SESSION_PAGE_LIMIT), cursor: { next: "next" } }
return { data: all.slice(HOME_V2_SESSION_PAGE_LIMIT), cursor: {} }
})
expect(result).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
expect(calls.map((call) => call.cursor)).toEqual([undefined, "next"])
expect(calls.every((call) => call.parentID === null)).toBe(true)
// Newest rows in server order, plus the recent window beyond the limit.
expect(ids(result)).toEqual(ids(all.slice(0, HOME_SESSION_INDEX_LIMIT + SESSION_RECENT_LIMIT)))
})
test("folds pages so every directory keeps its newest sessions", async () => {
const busy = history("/busy", HOME_V2_SESSION_PAGE_LIMIT + 300)
const quiet = history("/quiet", 3).map((item) => ({
...item,
time: { created: item.time.created - 6000 * minute, updated: item.time.updated - 6000 * minute },
}))
// Server order is global by updated time: /quiet is older than all of /busy
// and only arrives on the second page.
const result = await loadHomeSessionIndex(async (input) => {
if (!input.cursor) return { data: busy.slice(0, HOME_V2_SESSION_PAGE_LIMIT), cursor: { next: "next" } }
return { data: [...busy.slice(HOME_V2_SESSION_PAGE_LIMIT), ...quiet], cursor: {} }
})
expect(ids(result.filter((item) => item.location.directory === "/quiet"))).toEqual(ids(quiet))
expect(ids(result.filter((item) => item.location.directory === "/busy"))).toEqual(
ids(busy.slice(0, HOME_SESSION_INDEX_LIMIT + SESSION_RECENT_LIMIT)),
)
})
test("drops archived sessions before they can occupy a retained slot", async () => {
const archived = history("/repo", 200).map((item) => ({ ...item, time: { ...item.time, archived: now } }))
const live = history("/repo", 10).map((item) => ({
...item,
id: `live-${item.id}`,
time: { created: item.time.created - 300 * minute, updated: item.time.updated - 300 * minute },
}))
const result = await loadHomeSessionIndex(async () => ({ data: [...archived, ...live], cursor: {} }))
expect(ids(result)).toEqual(ids(live))
})
test("keeps only visible roots", () => {
@@ -38,7 +93,6 @@ describe("Home session index", () => {
})
test("preserves the per-directory retention limit", () => {
const now = Date.now()
const result = retainHomeSessions(
[session("a", { time: { created: 1, updated: 1 } }), session("b", { time: { created: 2, updated: 2 } })],
1,
@@ -47,3 +101,58 @@ describe("Home session index", () => {
expect(result.map((item) => item.id)).toEqual(["b"])
})
})
// Home merges locally known sessions and pending removals into the fetched
// index on every store change, then retains per directory for display and
// search. The loaded subset must resolve to the same set as the complete index.
describe("Home session index parity with the complete index", () => {
const complete = [...history("/a", 400), ...history("/b", 90), ...history("/c", 5)]
const view = (index: SessionInfo[], known: SessionInfo[], removed = new Set<string>()) =>
ids(
retainHomeSessions(
mergeHomeSessionIndex(index, known).filter((item) => !removed.has(item.id)),
HOME_SESSION_LIMIT,
now,
),
).toSorted()
const loaded = () => loadHomeSessionIndex(async () => ({ data: complete, cursor: {} }))
test("without local changes", async () => {
const result = view(await loaded(), [])
expect(result).toEqual(view(complete, []))
expect(result).toHaveLength(HOME_SESSION_LIMIT + SESSION_RECENT_LIMIT + 90 + 5)
})
test("with new, re-timed, and unlisted-directory local sessions", async () => {
const known = [
session("a-fresh", { time: { created: now, updated: now }, location: { directory: "/a" } }),
// A fetched session that fell outside retention but was updated locally.
{ ...complete[300], time: { created: now - 1, updated: now } },
session("d-new", { time: { created: now, updated: now }, location: { directory: "/d" } }),
]
const result = view(await loaded(), known)
expect(result).toEqual(view(complete, known))
expect(result).toContain("a-fresh")
expect(result).toContain(complete[300].id)
expect(result).toContain("d-new")
})
test("with pending removals up to the recent-window bucket size", async () => {
const removed = new Set(ids(complete.slice(0, SESSION_RECENT_LIMIT)))
const result = view(await loaded(), [], removed)
expect(result).toEqual(view(complete, [], removed))
expect(result).toHaveLength(HOME_SESSION_LIMIT + SESSION_RECENT_LIMIT + 90 + 5)
expect(result.some((id) => removed.has(id))).toBe(false)
})
test("with a directory entirely inside the recent window", async () => {
const hot = history("/hot", 300).map((item, index) => ({
...item,
time: { created: now - index * 1000 - 500, updated: now - index * 1000 },
}))
expect(hot.every((item) => item.time.updated > now - SESSION_RECENT_WINDOW)).toBe(true)
const index = await loadHomeSessionIndex(async () => ({ data: hot, cursor: {} }))
expect(index).toHaveLength(HOME_SESSION_INDEX_LIMIT + SESSION_RECENT_LIMIT)
expect(view(index, [])).toEqual(view(hot, []))
})
})
+14 -3
View File
@@ -3,6 +3,13 @@ import { pathKey } from "@/workspaces/path-key"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "@/runtime/server/global-sync/types"
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export const HOME_SESSION_LIMIT = 64
// Rows kept per directory from the fetched index: the visible limit plus the
// recent-window search bucket. Merging locally known sessions and pending
// removals into this subset resolves exactly like merging into the complete
// index, so Home shows the same rows, order, and search results while the
// query cache and every per-update re-merge stay bounded by directory count.
export const HOME_SESSION_INDEX_LIMIT = HOME_SESSION_LIMIT + SESSION_RECENT_LIMIT
export async function loadHomeSessionIndex(
list: (
@@ -16,7 +23,8 @@ export async function loadHomeSessionIndex(
) => Promise<SessionsResponse>,
signal?: AbortSignal,
) {
const data: SessionInfo[] = []
const now = Date.now()
let retained: SessionInfo[] = []
let cursor: string | undefined
for (;;) {
@@ -29,8 +37,11 @@ export async function loadHomeSessionIndex(
},
{ signal },
)
data.push(...response.data)
if (response.data.length < HOME_V2_SESSION_PAGE_LIMIT || !response.cursor.next) return parseHomeSessionIndex(data)
// Fold each page in as it arrives: pages are newest first, so later pages
// can only fill directories that still have room. Peak allocation is one
// page plus the retained subset, not the whole history.
retained = retainHomeSessions([...retained, ...parseHomeSessionIndex(response.data)], HOME_SESSION_INDEX_LIMIT, now)
if (response.data.length < HOME_V2_SESSION_PAGE_LIMIT || !response.cursor.next) return retained
cursor = response.cursor.next
}
}
+2
View File
@@ -911,6 +911,8 @@ export const dict = {
"settings.appearance.row.tabs.description": "Choose how session tabs are arranged",
"settings.appearance.row.tabs.horizontal": "Horizontal",
"settings.appearance.row.tabs.vertical": "Vertical",
"settings.appearance.row.projectName.title": "Show project names",
"settings.appearance.row.projectName.description": "Show project names in vertical tabs and the mobile tab drawer",
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
+3 -122
View File
@@ -1,126 +1,7 @@
import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import {
reuseTimelineRows,
Timeline,
TimelineRow,
type ReasoningMode,
} from "@opencode-ai/session-ui/timeline/projection"
import { createMemo, type Accessor } from "solid-js"
import { createReactiveTimelineProjection } from "@opencode-ai/session-ui/timeline/projection"
export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection"
export function createTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]>
status: Accessor<SessionStatus>
reasoningMode: Accessor<ReasoningMode>
shellToolDefaultOpen: Accessor<boolean>
editToolDefaultOpen: Accessor<boolean>
pendingUserMessageIDs: Accessor<ReadonlySet<string>>
}) {
const sessionMessageByID = createMemo(
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
)
const userContextByID = createMemo(() => {
const result = new Map<string, { agent: string; model: ModelRef }>()
let agent = ""
let model: ModelRef = { id: "", providerID: "" }
let userID: string | undefined
input.sessionMessages().forEach((message) => {
if (message.type === "agent-switched") agent = message.agent
if (message.type === "model-switched") model = message.model
if (message.type === "user") {
userID = message.id
const metadata = message.metadata
const localAgent = typeof metadata?.agent === "string" ? metadata.agent : agent
const localModel = metadata?.model
const localModelID =
localModel && typeof localModel === "object" && !Array.isArray(localModel)
? typeof localModel.id === "string"
? localModel.id
: typeof localModel.modelID === "string"
? localModel.modelID
: undefined
: undefined
result.set(message.id, {
agent: localAgent,
model:
localModel &&
typeof localModel === "object" &&
!Array.isArray(localModel) &&
localModelID &&
typeof localModel.providerID === "string"
? {
id: localModelID,
providerID: localModel.providerID,
variant: typeof localModel.variant === "string" ? localModel.variant : undefined,
}
: model,
})
}
if (message.type === "shell") userID = undefined
if (message.type !== "assistant") return
agent = message.agent
model = message.model
if (userID) result.set(userID, { agent, model })
})
return result
})
const assistantMessagesByParent = createMemo(() => {
const result = new Map<string, Extract<SessionMessageInfo, { type: "assistant" }>[]>()
let userID: string | undefined
input.sessionMessages().forEach((message) => {
if (message.type === "user") userID = message.id
if (message.type === "shell") userID = undefined
if (message.type !== "assistant") return
if (!userID) userID = message.id
const messages = result.get(userID)
if (messages) {
messages.push(message)
return
}
result.set(userID, [message])
})
return result
})
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
input.reasoningMode() !== "hidden",
input.status(),
input.pendingUserMessageIDs(),
input.shellToolDefaultOpen(),
input.editToolDefaultOpen(),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, projection().rows),
)
const indexes = createMemo(() => {
const rowByKey = new Map<string, TimelineRow.TimelineRow>()
const messageRowIndex = new Map<string, number>()
const messageLastRowIndex = new Map<string, number>()
const lastAssistantGroupKey = new Map<string, string>()
rows().forEach((row, index) => {
rowByKey.set(TimelineRow.key(row), row)
if (!("userMessageID" in row)) return
if (!messageRowIndex.has(row.userMessageID)) messageRowIndex.set(row.userMessageID, index)
messageLastRowIndex.set(row.userMessageID, index)
if (row._tag === "AssistantPart") lastAssistantGroupKey.set(row.userMessageID, row.group.key)
})
return { rowByKey, messageRowIndex, messageLastRowIndex, lastAssistantGroupKey }
})
return {
activeMessageID,
assistantMessagesByParent,
lastAssistantGroupKey: () => indexes().lastAssistantGroupKey,
messageByID: sessionMessageByID,
messageRowIndex: () => indexes().messageRowIndex,
messageLastRowIndex: () => indexes().messageLastRowIndex,
rowByKey: () => indexes().rowByKey,
rows,
sessionMessageByID,
userContextByID,
}
export function createTimelineProjection(input: Parameters<typeof createReactiveTimelineProjection>[0]) {
return createReactiveTimelineProjection(input)
}
@@ -1,6 +1,7 @@
import { Component, createMemo } from "solid-js"
import { Select } from "@opencode-ai/ui/select"
import { TextInput } from "@opencode-ai/ui/text-input"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
import { ExternalLink } from "@/runtime/platform/external-link"
import { SettingsList } from "@/settings/list"
@@ -149,6 +150,20 @@ export const SettingsAppearance: Component = () => {
onSelect={(option) => option && appearance.tabs.select(option)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.appearance.row.projectName.title")}
description={language.t("settings.appearance.row.projectName.description")}
>
<div data-action="settings-show-project-name">
<Switch
checked={appearance.projectName.current()}
onChange={appearance.projectName.set}
hideLabel
>
{language.t("settings.appearance.row.projectName.title")}
</Switch>
</div>
</SettingsRow>
</SettingsList>
</div>
</div>
@@ -85,6 +85,10 @@ export function createAppearanceSettingsController() {
current: settings.appearance.tabLayout,
select: settings.appearance.setTabLayout,
},
projectName: {
current: settings.appearance.showProjectName,
set: settings.appearance.setShowProjectName,
},
}
}
+10 -2
View File
@@ -101,7 +101,14 @@ describe("settings schema", () => {
terminalPlacement: "side",
followUpBehavior: "steer",
},
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal" },
appearance: {
fontSize: 14,
mono: "",
sans: "",
terminal: "",
tabLayout: "horizontal",
showProjectName: false,
},
keybinds: {},
permissions: { autoApprove: false },
workspaces: { defaultDestination: "last-used", lastUsed: {} },
@@ -126,7 +133,7 @@ describe("settings schema", () => {
reasoningMode: 3,
followUpBehavior: "invalid",
},
appearance: { fontSize: "large", mono: "Custom Mono", tabLayout: "vertical" },
appearance: { fontSize: "large", mono: "Custom Mono", tabLayout: "vertical", showProjectName: true },
permissions: { autoApprove: true },
workspaces: { defaultDestination: "new", lastUsed: { good: "workspace", bad: true } },
keybinds: { good: "ctrl+k", bad: 3 },
@@ -146,6 +153,7 @@ describe("settings schema", () => {
sans: "",
terminal: "",
tabLayout: "vertical",
showProjectName: true,
})
expect(settings.permissions.autoApprove).toBe(true)
expect(settings.workspaces).toEqual({ defaultDestination: "new", lastUsed: { good: "workspace" } })
+9 -1
View File
@@ -95,6 +95,7 @@ const appearanceSchema = Persistence.struct({
sans: Schema.String,
terminal: Schema.String,
tabLayout: Schema.Literals(["horizontal", "vertical"]),
showProjectName: Schema.Boolean,
})
const permissionsSchema = Persistence.struct({
@@ -179,7 +180,7 @@ export const defaultSettings: Settings = {
terminalPlacement: "side",
followUpBehavior: "steer",
},
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal" },
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
keybinds: {},
permissions: { autoApprove: false },
workspaces: { defaultDestination: "last-used", lastUsed: {} },
@@ -327,6 +328,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setTabLayout(value: TabLayout) {
setStore("appearance", "tabLayout", value)
},
showProjectName: withFallback(
() => store.appearance?.showProjectName,
defaultSettings.appearance.showProjectName,
),
setShowProjectName(value: boolean) {
setStore("appearance", "showProjectName", value)
},
},
keybinds: {
get: (action: string) => store.keybinds?.[action],
+2 -2
View File
@@ -15,11 +15,11 @@
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab][data-orientation="vertical"]:has([data-slot="project-avatar-slot"]) {
[data-titlebar-tab][data-orientation="vertical"]:has([data-slot="tab-project"]) {
height: 45px;
}
[data-titlebar-tab][data-orientation="vertical"]:has([data-slot="project-avatar-slot"]) [data-slot="tab-link"] {
[data-titlebar-tab][data-orientation="vertical"]:has([data-slot="tab-project"]) [data-slot="tab-link"] {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
grid-template-rows: repeat(2, var(--line-height-compact));
+3 -1
View File
@@ -14,6 +14,7 @@ import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { sessionLabel } from "@/session/title"
import { useSettings } from "@/settings/model"
import { canOpenTabRename, forwardTabRef } from "./tab-gesture"
import { TabPreviewPopover } from "./tab-popover"
import "./tab-nav.css"
@@ -39,6 +40,7 @@ export function TabNavItem(props: {
orientation?: "horizontal" | "vertical"
}) {
const language = useLanguage()
const settings = useSettings()
const [menu, setMenu] = createStore({ open: false, rename: false })
const [editing, setEditing] = createSignal(false)
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
@@ -298,7 +300,7 @@ export function TabNavItem(props: {
event.preventDefault()
}}
/>
<Show when={props.orientation === "vertical" && projectName()}>
<Show when={props.orientation === "vertical" && settings.appearance.showProjectName() && projectName()}>
{(name) => (
<span data-slot="tab-project" dir="auto">
{name()}
@@ -0,0 +1,292 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { createStore, produce } from "solid-js/store"
import {
createReactiveTimelineProjection,
reuseTimelineRows,
Timeline,
TimelineRow,
type ReasoningMode,
} from "@opencode-ai/session-ui/timeline/projection"
import { createTimelineProjection } from "../src/session/timeline/projection"
const assistant = (id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant => ({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
time: { created: 2 },
content,
})
for (const factory of [createTimelineProjection, createReactiveTimelineProjection]) {
describe(factory.name, () => {
test("only crosses the renderable boundary on text deltas, while retaining live content", () => {
createRoot((dispose) => {
let visits = 0
const [state, setState] = createStore({
messages: [
{
id: "old",
type: "user",
text: "history",
get time() {
visits++
return { created: 0 }
},
},
assistant("old-answer", [{ type: "text", text: "History remains visible." }]),
{ id: "user", type: "user", text: "question", time: { created: 1 } },
assistant("answer", [{ type: "text", text: "" }]),
] as SessionMessageInfo[],
})
const projection = factory({
sessionMessages: () => state.messages,
status: () => ({ type: "busy" }),
reasoningMode: () => "compact",
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
pendingUserMessageIDs: () => new Set(),
})
const update = (text: string) =>
setState(
"messages",
3,
produce((message) => {
if (message.type === "assistant" && message.content[0].type === "text") message.content[0].text = text
}),
)
const empty = projection.rows()
visits = 0
update(" \n\t")
expect(projection.rows()).toBe(empty)
expect(visits).toBe(0)
update("The first visible answer.")
const visible = projection.rows()
expect(visible.length).toBe(empty.length + 1)
expect(visits).toBeGreaterThan(0)
visits = 0
update("The first visible answer. More streamed words.")
expect(projection.rows()).toBe(visible)
expect(visits).toBe(0)
expect(Timeline.resolveContent(projection.messageByID().get("answer"), "answer:text:0")).toMatchObject({
text: "The first visible answer. More streamed words.",
})
update("")
expect(projection.rows()).toEqual(empty)
expect(visits).toBeGreaterThan(0)
dispose()
})
})
test("matches full construction through grouping, notices, history and preference transitions", () => {
createRoot((dispose) => {
const [state, setState] = createStore({
messages: [
assistant("answer", [
{ type: "reasoning", text: "Inspect the source", time: { created: 1 } },
{
type: "tool",
id: "read",
name: "read",
state: { status: "completed", input: {}, content: [{ type: "text", text: "read" }], metadata: {} },
time: { created: 1 },
},
{ type: "text", text: "" },
{
type: "tool",
id: "shell",
name: "shell",
state: { status: "running", input: {}, metadata: {} },
time: { created: 1 },
},
{
type: "tool",
id: "question",
name: "question",
state: { status: "running", input: {}, metadata: {} },
time: { created: 1 },
},
]),
] as SessionMessageInfo[],
status: { type: "busy" } as SessionStatus,
reasoning: "compact" as ReasoningMode,
shell: false,
edit: false,
pending: new Set<string>(),
})
const projection = factory({
sessionMessages: () => state.messages,
status: () => state.status,
reasoningMode: () => state.reasoning,
shellToolDefaultOpen: () => state.shell,
editToolDefaultOpen: () => state.edit,
pendingUserMessageIDs: () => state.pending,
})
let previous: TimelineRow.TimelineRow[] | undefined
const verify = () => {
const full = Timeline.constructSessionMessageRows(
state.messages,
state.reasoning !== "hidden",
state.status,
state.pending,
state.shell,
state.edit,
)
previous = reuseTimelineRows(previous, full.rows)
expect(projection.rows()).toEqual(previous)
expect(projection.activeMessageID()).toBe(full.activeMessageID)
expect([...projection.rowByKey().keys()]).toEqual(previous.map(TimelineRow.key))
expect([...projection.messageByID().keys()]).toEqual(state.messages.map((message) => message.id))
previous.forEach((row, index) => {
expect(projection.messageRowIndex().get(row.userMessageID)).toBe(
previous!.findIndex((item) => item.userMessageID === row.userMessageID),
)
expect(projection.messageLastRowIndex().get(row.userMessageID)).toBe(
previous!.findLastIndex((item) => item.userMessageID === row.userMessageID),
)
expect(projection.rowByKey().get(TimelineRow.key(row))).toBe(projection.rows()[index])
})
}
const change = (update: (message: SessionMessageAssistant) => void) => {
setState(
"messages",
(message) => message.id === "answer",
produce((message) => {
if (message.type === "assistant") update(message)
}),
)
verify()
}
verify()
change((message) => {
if (message.content[2].type === "text") message.content[2].text = "Split the context group"
})
change((message) => {
if (message.content[2].type === "text") message.content[2].text = " \n"
})
change((message) => {
if (message.content[0].type === "reasoning") message.content[0].text += " and its callers"
})
setState("reasoning", "hidden")
verify()
setState("reasoning", "full")
verify()
change((message) => {
if (message.content[1].type === "tool" && message.content[1].state.status === "completed")
message.content[1].state.metadata = { loaded: ["src/example.ts"] }
})
change((message) => {
if (message.content[3].type === "tool")
message.content[3].state = {
status: "completed",
input: {},
metadata: {},
content: [{ type: "text", text: "ok" }],
}
})
setState("shell", true)
verify()
change((message) => {
if (message.content[4].type === "tool")
message.content[4].state = {
status: "completed",
input: {},
metadata: {},
content: [{ type: "text", text: "answered" }],
}
})
change((message) => {
message.content.push({
type: "tool",
id: "edit",
name: "edit",
state: {
status: "completed",
input: {},
metadata: { files: [{ status: "deleted" }] },
content: [{ type: "text", text: "edited" }],
},
time: { created: 1 },
})
})
setState("edit", true)
verify()
change((message) => {
const tool = message.content.at(-1)
if (tool?.type === "tool" && tool.state.status === "completed")
tool.state.metadata = { files: [{ status: "modified" }] }
})
change((message) => {
message.content.push({ type: "reasoning", text: "Working", time: { created: 3 } })
})
expect(projection.rows().at(-1)?._tag).toBe("Thinking")
change((message) => {
const part = message.content.at(-1)
if (part?.type === "reasoning") part.time = { created: 3, completed: 4 }
})
change((message) => {
message.retry = { attempt: 1, at: 3, error: { type: "Retry", message: "retry" } }
})
change((message) => {
message.retry = undefined
message.error = { type: "Interrupted", message: "stopped" }
})
change((message) => {
message.error = { type: "Error", message: "failed" }
})
change((message) => {
message.error = undefined
message.time.completed = 5
})
setState("status", { type: "idle" })
verify()
setState(
"messages",
produce((messages) =>
messages.unshift({ id: "user", type: "user", text: "Earlier page", time: { created: 0 } }),
),
)
verify()
expect(projection.assistantMessagesByParent().get("user")?.[0].id).toBe("answer")
expect(projection.userContextByID().get("user")?.agent).toBe("build")
setState(
"messages",
produce((messages) =>
messages.push(
{ id: "notice", type: "synthetic", text: "hidden", time: { created: 6 } },
assistant("later", [{ type: "text", text: "Later work" }]),
),
),
)
verify()
setState("messages", (message) => message.id === "notice", { description: "Visible notice" })
verify()
setState(
"messages",
produce((messages) => messages.push({ id: "pending", type: "user", text: "Steer", time: { created: 8 } })),
)
setState("pending", new Set(["pending"]))
verify()
expect(projection.activeMessageID()).toBe("user")
setState("pending", new Set())
verify()
expect(projection.activeMessageID()).toBe("pending")
const all = [...state.messages]
setState("messages", (messages) => messages.slice(0, 2))
verify()
setState("messages", all)
verify()
change((message) => {
message.content = [{ type: "text", text: "Replacement" }]
})
setState("messages", [])
verify()
setState("messages", [assistant("new-session", [{ type: "text", text: "New session" }])])
verify()
dispose()
})
})
})
}
+5 -3
View File
@@ -9,7 +9,7 @@ import { Location } from "../../location.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { SessionInstructions } from "../../session/instructions.js"
import { AbsolutePath } from "../../schema.js"
import { AbsolutePath, NonNegativeInt } from "../../schema.js"
import { ReadToolFileSystem } from "../read-filesystem.js"
import { Environment } from "../../environment/index.js"
@@ -17,10 +17,12 @@ export const name = "read"
const FILENAME = "AGENTS.md"
const LocationInput = Schema.Struct({
path: Schema.String.annotate({ description: "File or directory to read" }),
offset: ReadToolFileSystem.PageInput.fields.offset.annotate({
offset: Schema.optionalKey(NonNegativeInt).annotate({
description: "The line or directory entry to start reading from (1-based)",
}),
limit: ReadToolFileSystem.PageInput.fields.limit.annotate({
limit: Schema.optionalKey(
NonNegativeInt.check(Schema.isLessThanOrEqualTo(ReadToolFileSystem.MAX_READ_LINES)),
).annotate({
description: "The maximum number of lines or directory entries to read (defaults to 2000)",
}),
})
@@ -0,0 +1,40 @@
# Diff Highlighting
Manual Chromium benchmark of the production `File` component, `normalize`, Pierre
pool, and bundled workers. No OpenCode server, session, or stored data is used.
From `packages/session-ui`, set `HIGHLIGHT_BUNDLE` to an external artifact
directory, then run:
```sh
bun performance/highlighting/build.ts
bun x playwright test --config performance/highlighting/playwright.config.ts --repeat-each 20
```
Each isolated page measures a cold mount, unmount/remount of the identical patch,
and an edited patch under the same filename. The fixtures contain 120 or 1,200
TypeScript route handlers with one changed line per ten handlers. Exact byte and
line counts, bundle hash, source revision, and individual samples are reported
through the existing performance reporter.
`readyMs` ends when the viewer has called `onRendered`, its pool has settled,
and `onPostRender` has committed the final worker result containing the expected
edit. `firstReadyMs` records the earlier production callback, which can represent
plain first paint. Worker request counts and round-trip time describe the
mechanism, not CPU time or desktop memory. Full reconstructed content is checked
separately after timing. No forced GC or machine-dependent thresholds are used.
`HIGHLIGHT_CORRECTNESS=1` runs the deterministic `*.correctness.ts` checks against
the same fixture instead of the timed benchmarks: cache reuse across remounts,
invalidation on content, theme, and worker option changes, and plain rendering of
large diffs with complete reconstructed content.
Optional output settings: `HIGHLIGHT_RESULTS`, `HIGHLIGHT_SCREENSHOTS`,
`OPENCODE_PERFORMANCE_RUN_ID`, and `OPENCODE_PERFORMANCE_TRACE_DIR` (separate
diagnostic runs, not clean timing). `HIGHLIGHT_PORT` defaults to 4793. Preserve the
baseline build before product edits, then point `HIGHLIGHT_BUNDLE` at either frozen
build to compare the same workload without rebuilding.
`HIGHLIGHT_RETENTION=1` enables a separate post-unmount, forced-GC renderer-isolate
heap diagnostic. Do not use that run for timing or describe it as desktop RAM;
it excludes worker heaps and native/browser-process memory.
@@ -0,0 +1,28 @@
import { build } from "vite"
import solid from "vite-plugin-solid"
import path from "node:path"
import { realpathSync } from "node:fs"
import { createHash } from "node:crypto"
const outDir = process.env.HIGHLIGHT_BUNDLE
if (!outDir) throw new Error("Set HIGHLIGHT_BUNDLE to an external artifact directory")
const ui = realpathSync(path.resolve(import.meta.dir, "../../node_modules/@opencode-ai/ui"))
if (ui !== realpathSync(path.resolve(import.meta.dir, "../../../ui"))) throw new Error(`Wrong workspace source: ${ui}`)
const util = realpathSync(path.resolve(import.meta.dir, "../../node_modules/@opencode-ai/util"))
if (util !== realpathSync(path.resolve(import.meta.dir, "../../../util"))) throw new Error(`Wrong workspace source: ${util}`)
await build({
configFile: false,
logLevel: "warn",
root: import.meta.dir,
plugins: [solid()],
build: { outDir, emptyOutDir: true, sourcemap: true },
worker: { format: "es" },
})
const files = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: outDir, onlyFiles: true }))
const hash = createHash("sha256")
for (const file of files.sort()) hash.update(file).update(new Uint8Array(await Bun.file(path.join(outDir, file)).arrayBuffer()))
await Bun.write(path.join(outDir, "build.json"), JSON.stringify({
revision: Bun.spawnSync(["git", "rev-parse", "HEAD"]).stdout.toString().trim(),
sourceDiff: Bun.spawnSync(["git", "diff", "--", "src/components/session-diff.ts", "src/components/file.tsx", "src/pierre/worker.ts"]).stdout.toString(),
bundle: hash.digest("hex"), bun: Bun.version, ui, util,
}, null, 2))
@@ -0,0 +1,35 @@
import { expect, test } from "@playwright/test"
import type {} from "./viewer"
test("reuses only matching patch, theme, and worker options", async ({ page }) => {
await page.goto("/")
await page.waitForFunction(() => !!window.highlighting)
const cold = await page.evaluate(() => window.highlighting.mount(1))
expect(cold.workerRequests).toBe(1)
expect(cold.cacheSize).toBe(1)
const first = await page.locator('[data-line="11"][data-line-type="change-addition"]').innerHTML()
await page.evaluate(() => window.highlighting.unmount())
const cached = await page.evaluate(() => window.highlighting.mount(1))
expect(cached.workerRequests).toBe(0)
expect(await page.locator('[data-line="11"][data-line-type="change-addition"]').innerHTML()).toBe(first)
await page.evaluate(() => window.highlighting.unmount())
const changed = await page.evaluate(() => window.highlighting.mount(2))
expect(changed.workerRequests).toBe(1)
await expect(page.locator('[data-line="11"][data-line-type="change-addition"]')).toContainText("status: 202")
await page.evaluate(() => window.highlighting.unmount())
await page.evaluate(() => window.highlighting.configure({ theme: "github-dark" }))
const themed = await page.evaluate(() => window.highlighting.mount(2))
expect(themed.workerRequests).toBe(1)
expect(themed.options.theme).toBe("github-dark")
expect(themed.cacheSize).toBe(1)
await page.evaluate(() => window.highlighting.unmount())
await page.evaluate(() => window.highlighting.configure({ tokenizeMaxLineLength: 1 }))
const plain = await page.evaluate(() => window.highlighting.mount(2))
expect(plain.workerRequests).toBe(1)
expect(plain.options.tokenizeMaxLineLength).toBe(1)
expect(await page.evaluate(() => window.highlighting.contents()!.after === window.highlighting.input.after[1])).toBe(true)
})
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en" data-color-scheme="light">
<head><meta charset="UTF-8" /><title>Diff highlighting benchmark</title></head>
<body><div id="root"></div><script type="module" src="./viewer.tsx"></script></body>
</html>
@@ -0,0 +1,19 @@
import { expect, test } from "@playwright/test"
import type {} from "./viewer"
test("renders large diffs as plain text without changing ordinary highlighting", async ({ page }) => {
await page.goto("/?large")
await page.waitForFunction(() => !!window.highlighting)
const large = await page.evaluate(() => window.highlighting.mount(1))
expect(large.options).toMatchObject({ lineDiffType: "none", maxLineDiffLength: 0, tokenizeMaxLineLength: 1 })
expect(large.syntaxSpans).toBe(0)
await expect(page.locator('[data-line="11"][data-line-type="change-addition"]')).toContainText("status: 201")
expect(await page.evaluate(() => window.highlighting.contents()!.after === window.highlighting.input.after[0])).toBe(true)
await page.goto("/")
await page.waitForFunction(() => !!window.highlighting)
const ordinary = await page.evaluate(() => window.highlighting.mount(1))
expect(ordinary.options).toMatchObject({ lineDiffType: "word-alt", maxLineDiffLength: 1000, tokenizeMaxLineLength: 1000 })
expect(ordinary.syntaxSpans).toBeGreaterThan(0)
await expect(page.locator('[data-line="11"][data-line-type="change-addition"]')).toContainText("status: 201")
})
@@ -0,0 +1,13 @@
import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: ".", testMatch: process.env.HIGHLIGHT_CORRECTNESS ? "*.correctness.ts" : "*.bench.ts", workers: 1, retries: 0, maxFailures: 1, timeout: 120_000,
outputDir: process.env.HIGHLIGHT_RESULTS,
reporter: [["line"]],
use: { baseURL: `http://127.0.0.1:${process.env.HIGHLIGHT_PORT ?? 4793}`, viewport: { width: 1280, height: 800 } },
webServer: {
command: "bun serve.ts",
url: `http://127.0.0.1:${process.env.HIGHLIGHT_PORT ?? 4793}`,
reuseExistingServer: false,
},
})
@@ -0,0 +1,11 @@
import path from "node:path"
const root = process.env.HIGHLIGHT_BUNDLE
if (!root) throw new Error("Set HIGHLIGHT_BUNDLE")
Bun.serve({
hostname: "127.0.0.1", port: Number(process.env.HIGHLIGHT_PORT ?? 4793),
fetch(request) {
const pathname = new URL(request.url).pathname
return new Response(Bun.file(path.join(root, pathname === "/" ? "index.html" : pathname)))
},
})
@@ -0,0 +1,41 @@
import { benchmark, expect } from "../../../app/e2e/performance/benchmark"
import { readFile } from "node:fs/promises"
import path from "node:path"
import type {} from "./viewer"
for (const large of [false, true]) {
benchmark(large ? "large file" : "normalized diff remount", async ({ page, browser, report }, info) => {
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
await page.goto(large ? "/?large" : "/")
await page.waitForFunction(() => !!window.highlighting)
const cold = await page.evaluate(() => window.highlighting.mount(1))
await expect(page.locator('[data-line="11"][data-line-type="change-addition"]')).toContainText("status: 201")
await page.evaluate(() => window.highlighting.unmount())
const remount = await page.evaluate(() => window.highlighting.mount(1))
await page.evaluate(() => window.highlighting.unmount())
const changed = await page.evaluate(() => window.highlighting.mount(2))
await expect(page.locator('[data-line="11"][data-line-type="change-addition"]')).toContainText("status: 202")
const complete = await page.evaluate(() => {
const contents = window.highlighting.contents()!
return contents.before === window.highlighting.input.before && contents.after === window.highlighting.input.after[1]
})
expect(complete).toBe(true)
expect(errors).toEqual([])
const retention = await (async () => {
if (!process.env.HIGHLIGHT_RETENTION) return
await page.evaluate(() => window.highlighting.unmount())
const session = await page.context().newCDPSession(page)
await session.send("HeapProfiler.collectGarbage")
const heap = await session.send("Runtime.getHeapUsage")
await session.detach()
return heap
})()
report({ cold, remount, changed, retention }, {
browser: browser.version(),
dimensions: await page.evaluate(() => window.highlighting.dimensions),
build: JSON.parse(await readFile(path.join(process.env.HIGHLIGHT_BUNDLE!, "build.json"), "utf8")),
})
if (process.env.HIGHLIGHT_SCREENSHOTS) await page.screenshot({ path: path.join(process.env.HIGHLIGHT_SCREENSHOTS, `${large ? "large" : "diff"}-${info.repeatEachIndex}.png`) })
})
}
@@ -0,0 +1,145 @@
import { render } from "solid-js/web"
import { createPatch } from "diff"
import type { WorkerRequest, WorkerResponse, WorkerRenderingOptions } from "@pierre/diffs/worker"
import type { RenderDiffOptions } from "@pierre/diffs"
import { File } from "../../src/components/file"
import { normalize, text } from "../../src/components/session-diff"
import { getWorkerPool } from "../../src/pierre/worker"
import "../../../ui/src/styles/theme.css"
// This fixture uses the production component, normalizer, pool, and worker bundle.
// Observe messages without replacing the worker or its implementation.
const messages: { type: string; at: number; end?: number }[] = []
const listening = new WeakSet<Worker>()
const pending = new Map<string, (typeof messages)[number]>()
const postMessage = Worker.prototype.postMessage
Worker.prototype.postMessage = function (message: WorkerRequest, options?: Transferable[] | StructuredSerializeOptions) {
if (!listening.has(this)) {
listening.add(this)
this.addEventListener("message", (event: MessageEvent<WorkerResponse>) => {
const item = pending.get(event.data.id)
if (item) item.end = performance.now()
pending.delete(event.data.id)
}, { capture: true })
}
const item = { type: message.type, at: performance.now() }
messages.push(item)
pending.set(message.id, item)
postMessage.call(this, message, Array.isArray(options) ? { transfer: options } : options)
}
function source(count: number, revision: number) {
return Array.from({ length: count }, (_, index) => {
const status = index % 10 === 0 ? 200 + revision : 200
return `export async function route${index}(request: Request, context: RouteContext) {
const account = await context.accounts.find(request.headers.get("account-id"))
if (!account) return new Response("Account not found", { status: 404 })
const payload = await request.json()
const record = await context.records.save({
accountId: account.id,
category: "route-${index}",
title: payload.title.trim(),
enabled: payload.enabled ?? true,
})
return Response.json({ id: record.id, status: ${status} })
}
`
}).join("\n")
}
const large = new URLSearchParams(location.search).has("large")
const before = source(large ? 1200 : 120, 0)
const inputs = [1, 2].map((revision) => {
const after = source(large ? 1200 : 120, revision)
return { file: "routes.ts", patch: createPatch("routes.ts", before, after, "", "", { context: Infinity }), after }
})
const host = document.getElementById("root")!
let dispose: VoidFunction | undefined
let active: ReturnType<typeof normalize> | undefined
document.head.insertAdjacentHTML("beforeend", `<style>
:root { color-scheme: light; --font-family-mono: monospace; --font-size-small: 13px;
--color-background-stronger: white; --v2-background-bg-accent: #007acc; --v2-text-text-accent: #005a9e; }
body { margin: 16px; } #root { height: 760px; overflow: auto; overflow-anchor: none; }
[data-slot="file-header"] { height: 40px; line-height: 40px; font: 13px system-ui; padding: 0 12px; }
</style>`)
type Measurement = {
readyMs: number
firstReadyMs: number
workerRequests: number
workerRoundTripMs: number
cacheSize: number
syntaxSpans: number
options: RenderDiffOptions
}
async function mount(revision: number) {
if (dispose) throw new Error("Unmount the previous viewer first")
const input = inputs[revision - 1]
const offset = messages.length
const start = performance.now()
active = normalize({ ...input, additions: large ? 120 : 12, deletions: large ? 120 : 12 })
const pool = getWorkerPool(large ? "none" : "word-alt")!
let firstReady = 0
let rendered = 0
let finish!: (value: Measurement) => void
const result = new Promise<Measurement>((resolve) => (finish = resolve))
const check = () => {
const stats = pool.getStats()
if (!firstReady || !rendered || stats.managerState !== "initialized" || stats.activeTasks || stats.queuedTasks || stats.busyWorkers) return
const work = messages.slice(offset).filter((item) => item.type === "diff")
if (work.some((item) => !item.end || rendered < item.end)) return
const root = host.querySelector("diffs-container")?.shadowRoot
const edit = root?.querySelector('[data-line="11"][data-line-type="change-addition"]')
if (!root || !edit?.textContent?.includes(`status: ${200 + revision}`)) return
const range = document.createRange()
range.selectNodeContents(edit)
const bounds = range.getBoundingClientRect()
if (bounds.top < host.getBoundingClientRect().top || bounds.bottom > host.getBoundingClientRect().bottom) return
finish({
readyMs: performance.now() - start,
firstReadyMs: firstReady - start,
workerRequests: work.length,
workerRoundTripMs: work.reduce((sum, item) => sum + item.end! - item.at, 0),
cacheSize: stats.diffCacheSize,
syntaxSpans: root.querySelectorAll('[data-line] [style*="--syntax-"]').length,
options: pool.getDiffRenderOptions(),
})
}
const unsubscribe = pool.subscribeToStatChanges(check)
// Production surfaces place a header or earlier content above the diff inside a `[role="log"]` scroll content
// element. An empty diff element at scroll offset 0 makes Pierre's virtualizer anchor its bottom edge and scroll
// the host by the full content height once the first rows render.
dispose = render(() => <div role="log">
<div data-slot="file-header">{input.file}</div>
<File mode="diff" fileDiff={active!.fileDiff}
onRendered={() => { firstReady ||= performance.now(); check() }}
onPostRender={(_, __, phase) => {
if (phase === "unmount") return
rendered = performance.now()
queueMicrotask(check)
}} />
</div>, host)
const value = await result
unsubscribe()
return value
}
export const highlighting = {
mount,
configure(options: Partial<WorkerRenderingOptions>) { return getWorkerPool(large ? "none" : "word-alt")!.setRenderOptions(options) },
unmount() { dispose?.(); dispose = undefined; host.scrollTop = 0 },
contents() { return active && { before: text(active, "deletions"), after: text(active, "additions") } },
input: { before, after: inputs.map((input) => input.after) },
dimensions: {
functions: large ? 1200 : 120,
beforeBytes: new TextEncoder().encode(before).length,
afterBytes: new TextEncoder().encode(inputs[0].after).length,
patchBytes: new TextEncoder().encode(inputs[0].patch).length,
lines: before.split("\n").length - 1,
},
}
declare global { interface Window { highlighting: typeof highlighting } }
window.highlighting = highlighting
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { createTwoFilesPatch } from "diff"
import { patchFile, patchFileGroups, patchFiles } from "./apply-patch-file"
import { text } from "./session-diff"
describe("apply patch files", () => {
test("parses current file diffs", () => {
@@ -80,4 +81,84 @@ describe("apply patch files", () => {
expect(groups).toHaveLength(1)
expect(groups[0]?.views).toHaveLength(2)
})
test.each(["\n", "\r\n"])("preserves complete chained contents with %j line endings", (newline) => {
const before = `const count = 1${newline}export { count }`
const middle = `const count = 2${newline}export { count }`
const after = `const count = 3${newline}export { count }`
const groups = patchFileGroups(
[before, middle].map((value, index) => ({
file: "count.ts",
patch: createTwoFilesPatch("count.ts", "count.ts", value, index === 0 ? middle : after, "", "", {
context: Infinity,
}),
status: "modified",
additions: 1,
deletions: 1,
})),
)
expect(groups).toHaveLength(1)
expect(groups[0]).toMatchObject({ type: "update", additions: 1, deletions: 1 })
expect(groups[0]!.views).toHaveLength(1)
expect(text(groups[0]!.views[0]!, "deletions")).toBe(before)
expect(text(groups[0]!.views[0]!, "additions")).toBe(after)
expect(groups[0]!.views).toBe(groups[0]!.views)
})
test("uses net counts for complete patches instead of producer counts", () => {
const groups = patchFileGroups([
{
file: "count.ts",
patch: createTwoFilesPatch("count.ts", "count.ts", "one\n", "two\n", "", "", { context: Infinity }),
status: "modified",
additions: 4,
deletions: 5,
},
])
expect(groups[0]).toMatchObject({ additions: 1, deletions: 1 })
expect(groups[0]!.views[0]).toMatchObject({ additions: 1, deletions: 1 })
})
test("keeps disconnected complete patches separate and preserves file order", () => {
const groups = patchFileGroups(
[
["b.ts", "one\n", "two\n"],
["a.ts", "first\n", "second\n"],
["b.ts", "three\n", "four\n"],
].map(([file, before, after]) => ({
file,
patch: createTwoFilesPatch(file!, file!, before!, after!, "", "", { context: Infinity }),
status: "modified",
additions: 1,
deletions: 1,
})),
)
expect(groups.map((group) => group.path)).toEqual(["b.ts", "a.ts"])
expect(groups[0]).toMatchObject({ additions: 2, deletions: 2 })
expect(groups[0]!.views.map((view) => text(view, "additions"))).toEqual(["two\n", "four\n"])
})
test.each([
["", "created\n", "", "added", "deleted", "delete", 0, 0],
["original\n", "changed\n", "original\n", "modified", "modified", "update", 0, 0],
["", "created\n", "changed\n", "added", "modified", "add", 1, 0],
])("preserves chain status and cancellation %#", (before, middle, after, first, last, type, additions, deletions) => {
const groups = patchFileGroups(
[
{ before, after: middle, status: first },
{ before: middle, after, status: last },
].map((value) => ({
file: "chain.ts",
patch: createTwoFilesPatch("chain.ts", "chain.ts", value.before, value.after, "", "", { context: Infinity }),
status: value.status,
additions: 1,
deletions: 1,
})),
)
expect(groups[0]).toMatchObject({ type, additions, deletions })
expect(groups[0]!.views).toHaveLength(1)
expect(text(groups[0]!.views[0]!, "deletions")).toBe(before)
expect(text(groups[0]!.views[0]!, "additions")).toBe(after)
})
})
@@ -1,5 +1,4 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { diffLines } from "diff"
import { completePatchContents, normalize, type ViewDiff } from "./session-diff"
type Kind = "add" | "update" | "delete"
@@ -28,12 +27,15 @@ export function changedFileDiff(value: unknown): value is FileDiffInfo {
export function patchFile(value: unknown): ApplyPatchFile | undefined {
if (!changedFileDiff(value)) return
let view: ViewDiff | undefined
return {
path: value.file,
type: value.status === "added" ? "add" : value.status === "deleted" ? "delete" : "update",
additions: value.additions,
deletions: value.deletions,
view: normalize(value),
get view() {
return (view ??= normalize(value))
},
contents: completePatchContents(value.patch),
}
}
@@ -67,12 +69,22 @@ export function patchFileGroups(value: unknown): ApplyPatchFileGroup[] {
}
}
const before = first.contents!.before
const after = last.contents!.after
const counts = diffLines(before, after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
const view =
files.length === 1
? first.view
: normalize({
file: path,
before: first.contents!.before,
after: last.contents!.after,
status: type === "add" ? "added" : type === "delete" ? "deleted" : "modified",
additions: 0,
deletions: 0,
})
// Parsed hunks already contain net change counts, excluding unchanged context.
const counts = view.fileDiff.hunks.reduce(
(result, hunk) => ({
additions: result.additions + hunk.additionLines,
deletions: result.deletions + hunk.deletionLines,
}),
{ additions: 0, deletions: 0 },
)
@@ -80,15 +92,7 @@ export function patchFileGroups(value: unknown): ApplyPatchFileGroup[] {
path,
type,
...counts,
views: [
normalize({
file: path,
before,
after,
status: type === "add" ? "added" : type === "delete" ? "deleted" : "modified",
...counts,
}),
],
views: [{ ...view, ...counts }],
}
})
}
@@ -127,6 +127,38 @@ describe("session diff", () => {
expect(resolveFileDiff({ file: "b.ts", patch }).name).toBe("b.ts")
})
test.each([
"@@ -1 +1 @@\n-old\n+new\n",
"--- a.ts\t\n+++ a.ts\t\n@@ -1 +1 @@\n-old\n+new\n",
])("reuses a highlight identity for the same cached patch: %s", (patch) => {
const first = resolveFileDiff({ file: "a.ts", patch })
expect(first.cacheKey).toBeString()
expect(resolveFileDiff({ file: "a.ts", patch }).cacheKey).toBe(first.cacheKey)
expect(resolveFileDiff({ file: "b.ts", patch }).cacheKey).not.toBe(first.cacheKey)
expect(resolveFileDiff({ file: "a.ts", patch: patch.replace("+new", "+next") }).cacheKey).not.toBe(first.cacheKey)
})
test("keys preloaded content diffs by file name and content", () => {
const diff = { file: "a.ts", before: "one\n", after: "two\n", additions: 1, deletions: 1 }
const first = normalize(diff).fileDiff
expect(first.cacheKey).toBeString()
expect(normalize(diff).fileDiff.cacheKey).toBe(first.cacheKey)
expect(normalize({ ...diff, file: "a.py" }).fileDiff.cacheKey).not.toBe(first.cacheKey)
expect(normalize({ ...diff, after: "three\n" }).fileDiff.cacheKey).not.toBe(first.cacheKey)
})
test("does not reuse an evicted highlight identity for different content", () => {
const patch = "@@ -1 +1 @@\n-old\n+new\n"
const first = resolveFileDiff({ file: "evicted.ts", patch })
const keys = Array.from({ length: 20 }, (_, index) =>
resolveFileDiff({ file: "evicted.ts", patch: patch.replace("+new", `+new${index}`) }).cacheKey,
)
expect(keys).not.toContain(first.cacheKey)
const restored = resolveFileDiff({ file: "evicted.ts", patch })
expect(restored.additionLines).toEqual(first.additionLines)
expect(restored.cacheKey).toBeString()
})
test("keeps capped header-only patches partial", () => {
const fileDiff = resolveFileDiff({
file: "a.ts",
@@ -1,5 +1,6 @@
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
import { parsePatch } from "diff"
import { checksum } from "@opencode-ai/util/encode"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { PresentationFileDiff } from "../file-presentation"
@@ -65,6 +66,8 @@ function fileDiffFromPatch(file: string, patch: string) {
const value = contents
? fileDiffFromContent(file, contents.before, contents.after)
: ((input ? parsePatchFiles(input)[0]?.files[0] : undefined) ?? emptyFileDiff(file))
// Complete patches already carry a content key from fileDiffFromContent; partial patches are keyed by the patch text.
value.cacheKey ??= highlightKey(key)
patchFileDiffCache.set(key, value)
while (patchFileDiffCache.size > diffCacheLimit) patchFileDiffCache.delete(patchFileDiffCache.keys().next().value!)
return value
@@ -136,7 +139,16 @@ function patchInput(file: string, patch: string) {
function fileDiffFromContent(file: string, before: string, after: string) {
if (!before && !after) return emptyFileDiff(file)
return parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
const value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
value.cacheKey = highlightKey(`${file}\0${before}\0${after}`)
return value
}
// Pierre reuses and dedups worker highlighting only for diffs that carry a cacheKey, and it treats diffs with equal
// keys as the same target. Derive the key from every input that shapes the highlighted output: the file name selects
// the language and the content selects the lines.
function highlightKey(value: string) {
return `${value.length}:${checksum(value) ?? 0}`
}
function emptyFileDiff(file: string) {
+3
View File
@@ -22,6 +22,9 @@ function createPool(lineDiffType: "none" | "word-alt") {
{
theme: "OpenCode",
lineDiffType,
// Pierre renders with the pool's options, not the viewer's, whenever the pool works. The "none" pool only
// serves diffs above the large-file threshold, so it carries the plain-text fallback the viewer requests.
...(lineDiffType === "none" && { maxLineDiffLength: 0, tokenizeMaxLineLength: 1 }),
preferredHighlighter: "shiki-wasm",
},
)
+21 -2
View File
@@ -7,7 +7,7 @@ import type {
SessionStatus,
} from "@opencode-ai/client/promise"
import { Option, Schema } from "effect"
import { createMemo, type Accessor } from "solid-js"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { currentContentDefaultOpen } from "../message/current-tool-state"
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
@@ -82,6 +82,18 @@ export function createReactiveTimelineProjection(input: {
)
const userContextByID = createMemo(() => indexUserContext(input.sessionMessages()))
const assistantMessagesByParent = createMemo(() => indexAssistantMessages(input.sessionMessages()))
// Row structure depends on the empty/non-empty boundary, not each text delta.
// Keep the original content objects so row renderers still read live text.
const textParts = mapArray(
() =>
input
.sessionMessages()
.flatMap((message) =>
message.type === "assistant" ? message.content.filter((content) => content.type !== "tool") : [],
),
(content) => [content, createMemo(() => !!content.text.trim())] as const,
)
const textVisible = createMemo(() => new Map<Content, Accessor<boolean>>(textParts()))
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
@@ -90,6 +102,10 @@ export function createReactiveTimelineProjection(input: {
input.pendingUserMessageIDs?.(),
input.shellToolDefaultOpen?.() ?? false,
input.editToolDefaultOpen?.() ?? false,
(content, showReasoning) =>
content.type === "tool"
? renderable(content, showReasoning)
: (content.type === "text" || showReasoning) && textVisible().get(content)!(),
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
@@ -140,6 +156,7 @@ export namespace Timeline {
pendingUserMessageIDs?: ReadonlySet<string>,
shellToolDefaultOpen = false,
editToolDefaultOpen = false,
isRenderable = renderable,
) {
type Turn = {
id: string
@@ -218,6 +235,7 @@ export namespace Timeline {
turn.id === activeMessageID,
shellToolDefaultOpen,
editToolDefaultOpen,
isRenderable,
)
}),
],
@@ -234,6 +252,7 @@ export namespace Timeline {
isActive: boolean,
shellToolDefaultOpen = false,
editToolDefaultOpen = false,
isRenderable = renderable,
) {
const rows: TimelineRow.TimelineRow[] = []
const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : []))
@@ -261,7 +280,7 @@ export namespace Timeline {
const appendAssistantSegment = (messages: SessionMessageAssistant[]) => {
const refs = messages.flatMap((message, messageIndex) =>
contentEntries(message)
.filter((entry) => renderable(entry.content, showReasoning) && !(thinking && entry.content === lastContent))
.filter((entry) => isRenderable(entry.content, showReasoning) && !(thinking && entry.content === lastContent))
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
)
const interruptedAt = messages.findIndex((message) => isInterrupted(message.error))
@@ -1027,7 +1027,9 @@ function toolErrorSubtitle(props: ToolProps, i18n: UiI18n) {
if (props.tool === "websearch") return text(props.input.query)
if (props.tool === "skill") return skillToolName(props.input, props.metadata)
if (props.tool === "patch") {
const count = patchFileGroups(props.metadata.files).length
const count = new Set(
Array.isArray(props.metadata.files) ? props.metadata.files.filter(changedFileDiff).map((file) => file.file) : [],
).size
if (count === 0) return undefined
return `${count} ${i18n.plural("ui.common.file", count)}`
}
+1 -1
View File
@@ -73,8 +73,8 @@ export function footerStatuslinePolicy(input: {
"agent",
"model",
"context",
"cost",
"provider",
"cost",
"menu",
]
const groups = order.flatMap((id) => selected.get(id) ?? [])
@@ -124,7 +124,7 @@ test.each([false, true])(
const model = width === 112 ? "GPT-5.6 Sol (50% Off) [max]" : mono ? "GPT-5.6... [max]" : "GPT-5.6\u2026 [max]"
expect(row).toBe(
(width === 112
? ["Build", model, "14.1K (1%)", "$0.04", "Anomaly / OpenCode", "ctrl+p menu"]
? ["Build", model, "14.1K (1%)", "Anomaly / OpenCode", "$0.04", "ctrl+p menu"]
: width === 40
? ["Build", model, "1% ctx"]
: ["Build", model]
+7 -1
View File
@@ -72,9 +72,15 @@ describe("run footer width", () => {
test("allocation priority is separate from placement", () => {
expect(footerStatuslinePolicy({ ...screenshot, width: 15 }).groups.map((group) => group.id)).toEqual(["model"])
expect(footerStatuslinePolicy({ ...screenshot, width: 60 }).groups.map((group) => group.id)).toEqual([
"agent",
"model",
"context",
"cost",
])
const full = footerStatuslinePolicy({ ...screenshot, width: 112 })
expect(full.text).toBe(
"Build \u00b7 GPT-5.6 Sol (50% Off) [max] \u00b7 14.1K (1%) \u00b7 $0.04 \u00b7 Anomaly / OpenCode \u00b7 ctrl+p menu",
"Build \u00b7 GPT-5.6 Sol (50% Off) [max] \u00b7 14.1K (1%) \u00b7 Anomaly / OpenCode \u00b7 $0.04 \u00b7 ctrl+p menu",
)
})