diff --git a/packages/app/e2e/performance/timeline/home-session-index-benchmark.spec.ts b/packages/app/e2e/performance/timeline/home-session-index-benchmark.spec.ts new file mode 100644 index 00000000000..ec3db196b4e --- /dev/null +++ b/packages/app/e2e/performance/timeline/home-session-index-benchmark.spec.ts @@ -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 + titles: Record +} + +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 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[] = [] + 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 +} + +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] +} diff --git a/packages/app/e2e/performance/timeline/home-session-index.fixture.ts b/packages/app/e2e/performance/timeline/home-session-index.fixture.ts new file mode 100644 index 00000000000..63cf9904fe7 --- /dev/null +++ b/packages/app/e2e/performance/timeline/home-session-index.fixture.ts @@ -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 + +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("") +} diff --git a/packages/app/e2e/performance/unit/home-session-index-fixture.test.ts b/packages/app/e2e/performance/unit/home-session-index-fixture.test.ts new file mode 100644 index 00000000000..534013b1835 --- /dev/null +++ b/packages/app/e2e/performance/unit/home-session-index-fixture.test.ts @@ -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) +}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index a77852dd73b..45bef80164e 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -609,9 +609,12 @@ export function currentSession(session: { id: string } & Record 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 } : {}), diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx index 78ab88a7bbd..7596c4a40ab 100644 --- a/packages/app/src/home/sessions/controller.tsx +++ b/packages/app/src/home/sessions/controller.tsx @@ -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" diff --git a/packages/app/src/home/sessions/index.test.ts b/packages/app/src/home/sessions/index.test.ts index b83f475c020..c2dd2a5f186 100644 --- a/packages/app/src/home/sessions/index.test.ts +++ b/packages/app/src/home/sessions/index.test.ts @@ -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 = {}) => ({ @@ -12,19 +21,65 @@ const session = (id: string, input: Partial = {}) => ...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()) => + 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, [])) + }) +}) diff --git a/packages/app/src/home/sessions/index.ts b/packages/app/src/home/sessions/index.ts index 185d5f9d171..c81e77371db 100644 --- a/packages/app/src/home/sessions/index.ts +++ b/packages/app/src/home/sessions/index.ts @@ -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, 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 } }