mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 15:06:21 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6948a3f5f5 | ||
|
|
d57e210f84 | ||
|
|
44a3bf2520 | ||
|
|
998086d6fb | ||
|
|
6ea388a206 | ||
|
|
651cdd257a | ||
|
|
e3c2e635a9 | ||
|
|
91a4c7bc32 |
@@ -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"]')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, []))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" } })
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user