fix(app): bound the Home session index to retained rows (#46786)

This commit is contained in:
Luke Parker
2026-09-02 20:34:25 +10:00
committed by GitHub
Unverified
parent 6ea388a206
commit 998086d6fb
7 changed files with 721 additions and 13 deletions
@@ -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)
})
+3
View File
@@ -609,9 +609,12 @@ export function currentSession(session: { id: string } & Record<string, unknown>
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
cost: session.cost ?? 0,
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...(typeof session.outcome === "string" ? { outcome: session.outcome } : {}),
time: {
created: "created" in time && typeof time.created === "number" ? time.created : 0,
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
...("idle" in time && typeof time.idle === "number" ? { idle: time.idle } : {}),
...("viewed" in time && typeof time.viewed === "number" ? { viewed: time.viewed } : {}),
...(session.time && typeof session.time === "object" && "archived" in session.time
? { archived: session.time.archived }
: {}),
@@ -7,7 +7,12 @@ import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
import {
HOME_SESSION_LIMIT,
loadHomeSessionIndex,
mergeHomeSessionIndex,
retainHomeSessions,
} from "@/home/sessions/index"
import type { LocalProject } from "@/shell/state/layout"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
@@ -25,8 +30,7 @@ import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
export type { HomeSessionRecord } from "./records"
const HOME_SESSION_LIMIT = 64
// Keep the large immutable result opaque so Solid Query does not recursively unwrap every session on mount.
// Keep the immutable result opaque so Solid Query does not recursively unwrap every session on mount.
const selectSessions = (sessions: SessionInfo[]) => () => sessions
export type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
+116 -7
View File
@@ -1,6 +1,15 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { HOME_V2_SESSION_PAGE_LIMIT, loadHomeSessionIndex, parseHomeSessionIndex, retainHomeSessions } from "./index"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "@/runtime/server/global-sync/types"
import {
HOME_SESSION_INDEX_LIMIT,
HOME_SESSION_LIMIT,
HOME_V2_SESSION_PAGE_LIMIT,
loadHomeSessionIndex,
mergeHomeSessionIndex,
parseHomeSessionIndex,
retainHomeSessions,
} from "./index"
const session = (id: string, input: Partial<SessionInfo> = {}) =>
({
@@ -12,19 +21,65 @@ const session = (id: string, input: Partial<SessionInfo> = {}) =>
...input,
}) as SessionInfo
// The loader anchors its recent window on the wall clock, so fixtures do too.
const now = Date.now()
const minute = 60_000
// One session per minute going back from `now`; index 0 is the newest, which
// is the server's list order.
const history = (directory: string, count: number) =>
Array.from({ length: count }, (_, index) =>
session(`${directory}-${String(index).padStart(5, "0")}`, {
time: { created: now - (index + 1) * minute - 1000, updated: now - (index + 1) * minute },
location: { directory },
}),
)
const ids = (sessions: SessionInfo[]) => sessions.map((item) => item.id)
describe("Home session index", () => {
test("loads all pages", async () => {
const first = Array.from({ length: HOME_V2_SESSION_PAGE_LIMIT }, (_, index) => session(`session-${index}`))
test("follows cursors across pages and bounds the result per directory", async () => {
const all = history("/repo", HOME_V2_SESSION_PAGE_LIMIT + 1)
const calls: Array<{ cursor?: string; parentID: null }> = []
const result = await loadHomeSessionIndex(async (input) => {
calls.push(input)
if (!input.cursor) return { data: first, cursor: { next: "next" } }
return { data: [session("last")], cursor: {} }
if (!input.cursor) return { data: all.slice(0, HOME_V2_SESSION_PAGE_LIMIT), cursor: { next: "next" } }
return { data: all.slice(HOME_V2_SESSION_PAGE_LIMIT), cursor: {} }
})
expect(result).toHaveLength(HOME_V2_SESSION_PAGE_LIMIT + 1)
expect(calls.map((call) => call.cursor)).toEqual([undefined, "next"])
expect(calls.every((call) => call.parentID === null)).toBe(true)
// Newest rows in server order, plus the recent window beyond the limit.
expect(ids(result)).toEqual(ids(all.slice(0, HOME_SESSION_INDEX_LIMIT + SESSION_RECENT_LIMIT)))
})
test("folds pages so every directory keeps its newest sessions", async () => {
const busy = history("/busy", HOME_V2_SESSION_PAGE_LIMIT + 300)
const quiet = history("/quiet", 3).map((item) => ({
...item,
time: { created: item.time.created - 6000 * minute, updated: item.time.updated - 6000 * minute },
}))
// Server order is global by updated time: /quiet is older than all of /busy
// and only arrives on the second page.
const result = await loadHomeSessionIndex(async (input) => {
if (!input.cursor) return { data: busy.slice(0, HOME_V2_SESSION_PAGE_LIMIT), cursor: { next: "next" } }
return { data: [...busy.slice(HOME_V2_SESSION_PAGE_LIMIT), ...quiet], cursor: {} }
})
expect(ids(result.filter((item) => item.location.directory === "/quiet"))).toEqual(ids(quiet))
expect(ids(result.filter((item) => item.location.directory === "/busy"))).toEqual(
ids(busy.slice(0, HOME_SESSION_INDEX_LIMIT + SESSION_RECENT_LIMIT)),
)
})
test("drops archived sessions before they can occupy a retained slot", async () => {
const archived = history("/repo", 200).map((item) => ({ ...item, time: { ...item.time, archived: now } }))
const live = history("/repo", 10).map((item) => ({
...item,
id: `live-${item.id}`,
time: { created: item.time.created - 300 * minute, updated: item.time.updated - 300 * minute },
}))
const result = await loadHomeSessionIndex(async () => ({ data: [...archived, ...live], cursor: {} }))
expect(ids(result)).toEqual(ids(live))
})
test("keeps only visible roots", () => {
@@ -38,7 +93,6 @@ describe("Home session index", () => {
})
test("preserves the per-directory retention limit", () => {
const now = Date.now()
const result = retainHomeSessions(
[session("a", { time: { created: 1, updated: 1 } }), session("b", { time: { created: 2, updated: 2 } })],
1,
@@ -47,3 +101,58 @@ describe("Home session index", () => {
expect(result.map((item) => item.id)).toEqual(["b"])
})
})
// Home merges locally known sessions and pending removals into the fetched
// index on every store change, then retains per directory for display and
// search. The loaded subset must resolve to the same set as the complete index.
describe("Home session index parity with the complete index", () => {
const complete = [...history("/a", 400), ...history("/b", 90), ...history("/c", 5)]
const view = (index: SessionInfo[], known: SessionInfo[], removed = new Set<string>()) =>
ids(
retainHomeSessions(
mergeHomeSessionIndex(index, known).filter((item) => !removed.has(item.id)),
HOME_SESSION_LIMIT,
now,
),
).toSorted()
const loaded = () => loadHomeSessionIndex(async () => ({ data: complete, cursor: {} }))
test("without local changes", async () => {
const result = view(await loaded(), [])
expect(result).toEqual(view(complete, []))
expect(result).toHaveLength(HOME_SESSION_LIMIT + SESSION_RECENT_LIMIT + 90 + 5)
})
test("with new, re-timed, and unlisted-directory local sessions", async () => {
const known = [
session("a-fresh", { time: { created: now, updated: now }, location: { directory: "/a" } }),
// A fetched session that fell outside retention but was updated locally.
{ ...complete[300], time: { created: now - 1, updated: now } },
session("d-new", { time: { created: now, updated: now }, location: { directory: "/d" } }),
]
const result = view(await loaded(), known)
expect(result).toEqual(view(complete, known))
expect(result).toContain("a-fresh")
expect(result).toContain(complete[300].id)
expect(result).toContain("d-new")
})
test("with pending removals up to the recent-window bucket size", async () => {
const removed = new Set(ids(complete.slice(0, SESSION_RECENT_LIMIT)))
const result = view(await loaded(), [], removed)
expect(result).toEqual(view(complete, [], removed))
expect(result).toHaveLength(HOME_SESSION_LIMIT + SESSION_RECENT_LIMIT + 90 + 5)
expect(result.some((id) => removed.has(id))).toBe(false)
})
test("with a directory entirely inside the recent window", async () => {
const hot = history("/hot", 300).map((item, index) => ({
...item,
time: { created: now - index * 1000 - 500, updated: now - index * 1000 },
}))
expect(hot.every((item) => item.time.updated > now - SESSION_RECENT_WINDOW)).toBe(true)
const index = await loadHomeSessionIndex(async () => ({ data: hot, cursor: {} }))
expect(index).toHaveLength(HOME_SESSION_INDEX_LIMIT + SESSION_RECENT_LIMIT)
expect(view(index, [])).toEqual(view(hot, []))
})
})
+14 -3
View File
@@ -3,6 +3,13 @@ import { pathKey } from "@/workspaces/path-key"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "@/runtime/server/global-sync/types"
export const HOME_V2_SESSION_PAGE_LIMIT = 5_000
export const HOME_SESSION_LIMIT = 64
// Rows kept per directory from the fetched index: the visible limit plus the
// recent-window search bucket. Merging locally known sessions and pending
// removals into this subset resolves exactly like merging into the complete
// index, so Home shows the same rows, order, and search results while the
// query cache and every per-update re-merge stay bounded by directory count.
export const HOME_SESSION_INDEX_LIMIT = HOME_SESSION_LIMIT + SESSION_RECENT_LIMIT
export async function loadHomeSessionIndex(
list: (
@@ -16,7 +23,8 @@ export async function loadHomeSessionIndex(
) => Promise<SessionsResponse>,
signal?: AbortSignal,
) {
const data: SessionInfo[] = []
const now = Date.now()
let retained: SessionInfo[] = []
let cursor: string | undefined
for (;;) {
@@ -29,8 +37,11 @@ export async function loadHomeSessionIndex(
},
{ signal },
)
data.push(...response.data)
if (response.data.length < HOME_V2_SESSION_PAGE_LIMIT || !response.cursor.next) return parseHomeSessionIndex(data)
// Fold each page in as it arrives: pages are newest first, so later pages
// can only fill directories that still have room. Peak allocation is one
// page plus the retained subset, not the whole history.
retained = retainHomeSessions([...retained, ...parseHomeSessionIndex(response.data)], HOME_SESSION_INDEX_LIMIT, now)
if (response.data.length < HOME_V2_SESSION_PAGE_LIMIT || !response.cursor.next) return retained
cursor = response.cursor.next
}
}