mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(app): bound the Home session index to retained rows (#46786)
This commit is contained in:
committed by
GitHub
Unverified
parent
6ea388a206
commit
998086d6fb
@@ -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,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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user