fix(app): make session navigation stable and fast (#33569)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Luke Parker 2026-06-24 17:48:54 +10:00 committed by GitHub
parent abcfeb380b
commit a4551a94b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1028 additions and 202 deletions

View File

@ -18,6 +18,8 @@ bun run test:bench
The suite contains: The suite contains:
- cold and hot session-tab timing - cold and hot session-tab timing
- home-session click timing split between content and titlebar-tab paint
- single-session tab close timing through stable home restoration
- cached session repaint and mutation tracing - cached session repaint and mutation tracing
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics - streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics

View File

@ -0,0 +1,87 @@
import { expectSessionTitle } from "../../utils/waits"
import { benchmark, expect } from "../benchmark"
import { measureFirstNavigation } from "./first-navigation-probe"
import { fixture } from "./session-timeline-stress.fixture"
import {
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressDraftHref,
stressSessionHref,
} from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
const contentSelector = '[data-message-id], [data-component="prompt-input"]'
const draftID = "draft_first_navigation"
benchmark.describe("performance: first navigation paint", () => {
benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.targetID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => {
await setup(page, draftID)
const href = stressDraftHref(draftID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: '[data-component="prompt-input"]',
contentSelector,
navigate: async () => {
await page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first().click()
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.childID)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: messageSelector(fixture.expected.sourceMessageIDs.at(-1)!),
destinationSelector: messageSelector(fixture.expected.childMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await page.locator(`a[href="${href}"]`, { has: page.locator('[data-component="task-tool-card"]') }).click()
await expectSessionTitle(page, fixture.expected.childTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
})
async function setup(page: Parameters<typeof mockStressTimeline>[0], draft?: string) {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, draft ? { draftID: draft } : undefined)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
function messageSelector(id: string) {
return `[data-message-id="${id}"]`
}

View File

@ -0,0 +1,34 @@
export type FirstNavigationSample = {
observedAtMs: number
source: boolean
destination: boolean
content: boolean
pathname?: string
center?: string
}
function category(sample: FirstNavigationSample) {
if (sample.destination && !sample.source) return "destination"
if (sample.source && !sample.destination) return "source"
if (!sample.content) return "blank"
return "unknown"
}
export function summarizeFirstNavigation(samples: FirstNavigationSample[]) {
const categories = samples.map(category)
const stable = categories.findIndex(
(value, index) =>
value === "destination" &&
categories[index + 1] === "destination" &&
categories[index + 2] === "destination",
)
return {
samples: samples.length,
firstDestinationObservedMs: samples[categories.indexOf("destination")]?.observedAtMs ?? null,
stableDestinationObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
sourceSamples: categories.filter((value) => value === "source").length,
blankSamples: categories.filter((value) => value === "blank").length,
unknownSamples: categories.filter((value) => value === "unknown").length,
destinationSamples: categories.filter((value) => value === "destination").length,
}
}

View File

@ -0,0 +1,85 @@
import type { Page } from "@playwright/test"
import { summarizeFirstNavigation, type FirstNavigationSample } from "./first-navigation-metrics"
type FirstNavigationProbe = {
samples: FirstNavigationSample[]
stop: () => void
}
export async function measureFirstNavigation(
page: Page,
input: {
href: string
destinationPath: string
sourceSelector: string
destinationSelector: string
contentSelector: string
navigate: () => Promise<void>
},
) {
await page.evaluate(
({ href, destinationPath, sourceSelector, destinationSelector, contentSelector }) => {
const samples: FirstNavigationSample[] = []
let started: number | undefined
let running = true
const visible = (selector: string) =>
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
})
const sample = () => {
if (!running || started === undefined) return
requestAnimationFrame(() => {
setTimeout(() => {
if (!running || started === undefined) return
samples.push({
observedAtMs: performance.now() - started,
source: visible(sourceSelector),
destination: `${location.pathname}${location.search}` === destinationPath && visible(destinationSelector),
content: visible(contentSelector),
pathname: `${location.pathname}${location.search}`,
center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
})
sample()
}, 0)
})
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
sample()
},
{ capture: true, once: true },
)
;(window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe = {
samples,
stop: () => {
running = false
},
}
},
{
href: input.href,
destinationPath: input.destinationPath,
sourceSelector: input.sourceSelector,
destinationSelector: input.destinationSelector,
contentSelector: input.contentSelector,
},
)
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe?.samples
if (!samples) return false
return samples.length >= 3 && samples.slice(-3).every((sample) => sample.destination && !sample.source)
})
const samples = await page.evaluate(() => {
const probe = (window as Window & { __firstNavigationProbe?: FirstNavigationProbe }).__firstNavigationProbe!
probe.stop()
return probe.samples
})
return { summary: summarizeFirstNavigation(samples), samples }
}

View File

@ -0,0 +1,114 @@
import { benchmark, expect } from "../benchmark"
import { expectSessionTitle } from "../../utils/waits"
import { measureNavigationMilestones } from "./navigation-milestones"
import { fixture } from "./session-timeline-stress.fixture"
import {
installStressSessionTabs,
installTimelineSettings,
mockStressTimeline,
stressSessionHref,
} from "./timeline-test-helpers"
import { waitForStableTimeline } from "./session-tab-switch-probe"
const homeRow = '[data-component="home-session-row"]'
const homeShell = '[data-component="home-session-search"]'
benchmark.describe("performance: home and tab navigation", () => {
benchmark("opens a home session and paints its titlebar tab", async ({ page, report }) => {
await setup(page, [])
await page.goto("/")
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
await expect(row).toBeVisible()
const href = stressSessionHref(fixture.targetID)
const result = await measureNavigationMilestones(page, {
triggerSelector: homeRow,
milestones: {
content: { selector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!) },
tab: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]` },
},
navigate: async () => {
await row.click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)).toContainText(
fixture.expected.targetTitle,
)
})
benchmark("stages the review body after cold session content", async ({ page, report }) => {
await setup(page, [])
await page.goto("/")
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
await expect(row).toBeVisible()
const result = await page.evaluate(
({ rowSelector, title, contentSelector }) =>
new Promise<{ contentBeforeReview: boolean; samples: number }>((resolve) => {
let samples = 0
const sample = () => {
samples++
const content = !!document.querySelector(contentSelector)
const review = !!document.querySelector('[data-component="session-review"]')
if (content && !review) {
resolve({ contentBeforeReview: true, samples })
return
}
if (content && review) {
resolve({ contentBeforeReview: false, samples })
return
}
requestAnimationFrame(sample)
}
const target = [...document.querySelectorAll<HTMLElement>(rowSelector)].find((item) =>
item.textContent?.includes(title),
)
if (!target) throw new Error(`Home session row not found: ${title}`)
target.click()
requestAnimationFrame(sample)
}),
{
rowSelector: homeRow,
title: fixture.expected.targetTitle,
contentSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
},
)
report(result)
expect(result.contentBeforeReview).toBe(true)
await expect(page.locator('[data-component="session-review"]')).toBeVisible()
})
benchmark("closes the only session tab and paints home", async ({ page, report }) => {
await setup(page, [fixture.sourceID])
const href = stressSessionHref(fixture.sourceID)
await page.goto(href)
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
const close = tab.locator("..").locator('[data-component="icon-button-v2"]')
await expect(close).toBeVisible()
const result = await measureNavigationMilestones(page, {
triggerSelector: '[data-slot="titlebar-tabs"] [data-component="icon-button-v2"]',
milestones: {
home: { selector: homeShell },
row: { selector: homeRow },
tabRemoved: { selector: `[data-slot="titlebar-tabs"] a[href="${href}"]`, visible: false },
},
navigate: async () => {
await close.click()
await expect(page).toHaveURL("/")
},
})
report(result)
})
})
async function setup(page: Parameters<typeof mockStressTimeline>[0], sessionIDs: string[]) {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, { sessionIDs })
}
function messageSelector(id: string) {
return `[data-message-id="${id}"]`
}

View File

@ -0,0 +1,123 @@
import type { Page } from "@playwright/test"
export type NavigationMilestoneSample = {
observedAtMs: number
milestones: Record<string, boolean>
}
export function summarizeNavigationMilestones(samples: NavigationMilestoneSample[]) {
const names = Object.keys(samples[0]?.milestones ?? {})
const summarize = (matches: (sample: NavigationMilestoneSample) => boolean) => {
const first = samples.find(matches)
const stable = samples.findIndex(
(sample, index) =>
index + 2 < samples.length && matches(sample) && matches(samples[index + 1]!) && matches(samples[index + 2]!),
)
return {
firstObservedMs: first?.observedAtMs ?? null,
stableObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
}
}
return {
samples: samples.length,
milestones: Object.fromEntries(names.map((name) => [name, summarize((sample) => sample.milestones[name] === true)])),
all: summarize((sample) => names.every((name) => sample.milestones[name] === true)),
}
}
type NavigationMilestoneProbe = {
samples: NavigationMilestoneSample[]
stop: () => void
}
export async function measureNavigationMilestones(
page: Page,
input: {
triggerSelector: string
milestones: Record<string, { selector: string; visible?: boolean }>
navigate: () => Promise<void>
},
) {
await page.evaluate(({ triggerSelector, milestones }) => {
const samples: NavigationMilestoneSample[] = []
const streaks = new Map<string, number>()
const marked = new Set<string>()
let started: number | undefined
let running = true
const visible = (selector: string) =>
[...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
})
const sample = () => {
if (!running || started === undefined) return
requestAnimationFrame(() => {
setTimeout(() => {
if (!running || started === undefined) return
const current = Object.fromEntries(
Object.entries(milestones).map(([name, milestone]) => [
name,
milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
]),
)
samples.push({
observedAtMs: performance.now() - started,
milestones: current,
})
Object.entries(current).forEach(([name, value]) => {
if (!value) {
streaks.set(name, 0)
return
}
if (!marked.has(`${name}.first`)) {
performance.mark(`opencode.navigation.${name}.first`)
marked.add(`${name}.first`)
}
const streak = (streaks.get(name) ?? 0) + 1
streaks.set(name, streak)
if (streak === 3) performance.mark(`opencode.navigation.${name}.stable`)
})
const all = Object.values(current).every(Boolean)
const allStreak = all ? (streaks.get("all") ?? 0) + 1 : 0
streaks.set("all", allStreak)
if (all && !marked.has("all.first")) {
performance.mark("opencode.navigation.all.first")
marked.add("all.first")
}
if (allStreak === 3) performance.mark("opencode.navigation.all.stable")
sample()
}, 0)
})
}
document.addEventListener(
"click",
(event) => {
if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
started = performance.now()
performance.mark("opencode.navigation.click")
sample()
},
{ capture: true, once: true },
)
;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
samples,
stop: () => {
running = false
},
}
}, { triggerSelector: input.triggerSelector, milestones: input.milestones })
await input.navigate()
await page.waitForFunction(() => {
const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
?.samples
if (!samples || samples.length < 3) return false
return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
})
const samples = await page.evaluate(() => {
const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
probe.stop()
return probe.samples
})
return { summary: summarizeNavigationMilestones(samples), samples }
}

View File

@ -47,3 +47,21 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
report(compressCachedRepaintTrace(result)) report(compressCachedRepaintTrace(result))
expect(result.samples.length).toBeGreaterThan(0) expect(result.samples.length).toBeGreaterThan(0)
}) })
benchmark("prefetches every open session tab", async ({ page, report }) => {
const prefetched = new Set<string>()
await mockStressTimeline(page, {
onMessages: (input) => {
if (!input.before && input.phase === "start") prefetched.add(input.sessionID)
},
})
await installStressSessionTabs(page, {
sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID],
})
await installTimelineSettings(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await expect.poll(() => prefetched.has(fixture.childID)).toBe(true)
report({ prefetched: [...prefetched] })
})

View File

@ -23,6 +23,7 @@ const words = [
const sourceID = "ses_smoke_source" const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target" const targetID = "ses_smoke_target"
const childID = "ses_smoke_child"
const directory = "C:/OpenCode/SmokeProject" const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline" const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
@ -126,9 +127,11 @@ function toolPart(
tool: string, tool: string,
input: Record<string, unknown>, input: Record<string, unknown>,
outputLength = 160, outputLength = 160,
metadataOverride?: Record<string, unknown>,
): MessagePart { ): MessagePart {
const metadata = const metadata =
tool === "apply_patch" metadataOverride ??
(tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write" : tool === "edit" || tool === "write"
? { ? {
@ -138,7 +141,7 @@ function toolPart(
} }
: tool === "question" : tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] } ? { answers: [["Proceed"], ["Keep sample output"]] }
: {} : {})
return { return {
id: id(`prt_tool_${tool}_${partIndex}`, index), id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool", type: "tool",
@ -244,7 +247,25 @@ function turn(index: number): Message[] {
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120), userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]), assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [
textPart(index + 1000, 0, 240),
...(index === 11
? [
toolPart(
index + 1000,
1,
"task",
{ description: "Inspect child navigation", subagent_type: "explore" },
160,
{ sessionId: childID },
),
]
: []),
]),
]).flat()
const childMessages = Array.from({ length: 4 }, (_, index) => [
userMessage(childID, index + 2000, 120),
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
]).flat() ]).flat()
function renderable(part: MessagePart) { function renderable(part: MessagePart) {
@ -298,19 +319,34 @@ export const fixture = {
version: "dev", version: "dev",
time: { created: 1700000001000, updated: 1700000001000 }, time: { created: 1700000001000, updated: 1700000001000 },
}, },
{
id: childID,
parentID: sourceID,
slug: "child",
projectID,
directory,
title: "Inspect child navigation",
version: "dev",
time: { created: 1700000002000, updated: 1700000002000 },
},
], ],
sourceID, sourceID,
targetID, targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages }, childID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
expected: { expected: {
sourceTitle: "Uncommitted changes inquiry", sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis", targetTitle: "Example Game: sample jump movement & sample physics analysis",
childTitle: "Inspect child navigation",
sourceMessageIDs: sourceMessages sourceMessageIDs: sourceMessages
.filter((message) => message.info.role === "user") .filter((message) => message.info.role === "user")
.map((message) => message.info.id), .map((message) => message.info.id),
targetMessageIDs: targetMessages targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user") .filter((message) => message.info.role === "user")
.map((message) => message.info.id), .map((message) => message.info.id),
childMessageIDs: childMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) => targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message) orderedParts(message)
.filter(renderable) .filter(renderable)

View File

@ -1,7 +1,7 @@
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { mockOpenCodeServer } from "../../utils/mock-server" import { mockOpenCodeServer } from "../../utils/mock-server"
import { fixture } from "./session-timeline-stress.fixture" import { fixture, pageMessages } from "./session-timeline-stress.fixture"
export async function installTimelineSettings(page: Page) { export async function installTimelineSettings(page: Page) {
await page.addInitScript(() => { await page.addInitScript(() => {
@ -9,6 +9,7 @@ export async function installTimelineSettings(page: Page) {
"settings.v3", "settings.v3",
JSON.stringify({ JSON.stringify({
general: { general: {
newLayoutDesigns: true,
editToolPartsExpanded: true, editToolPartsExpanded: true,
shellToolPartsExpanded: true, shellToolPartsExpanded: true,
showReasoningSummaries: true, showReasoningSummaries: true,
@ -19,20 +20,24 @@ export async function installTimelineSettings(page: Page) {
}) })
} }
export function mockStressTimeline(page: Page) { export function mockStressTimeline(
page: Page,
input?: { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void },
) {
return mockOpenCodeServer(page, { return mockOpenCodeServer(page, {
sessions: fixture.sessions, sessions: fixture.sessions,
provider: fixture.provider, provider: fixture.provider,
directory: fixture.directory, directory: fixture.directory,
project: fixture.project, project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), pageMessages,
onMessages: input?.onMessages,
}) })
} }
export async function installStressSessionTabs(page: Page) { export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) {
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const server = stressServer()
await page.addInitScript( await page.addInitScript(
({ directory, sourceID, targetID, dirBase64, server }) => { ({ directory, sessionIDs, dirBase64, server, draftID }) => {
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:server", "opencode.global.dat:server",
JSON.stringify({ JSON.stringify({
@ -43,25 +48,36 @@ export async function installStressSessionTabs(page: Page) {
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:tabs", "opencode.global.dat:tabs",
JSON.stringify( JSON.stringify(
[sourceID, targetID].map((sessionId) => ({ [
...sessionIDs.map((sessionId) => ({
type: "session", type: "session",
server, server,
dirBase64, dirBase64,
sessionId, sessionId,
})), })),
...(draftID ? [{ type: "draft", draftID, server, directory }] : []),
],
), ),
) )
}, },
{ {
directory: fixture.directory, directory: fixture.directory,
sourceID: fixture.sourceID, sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID],
targetID: fixture.targetID,
dirBase64: base64Encode(fixture.directory), dirBase64: base64Encode(fixture.directory),
server, server,
draftID: input?.draftID,
}, },
) )
} }
export function stressSessionHref(sessionID: string) { export function stressSessionHref(sessionID: string) {
return `/${base64Encode(fixture.directory)}/session/${sessionID}` return `/server/${base64Encode(stressServer())}/session/${sessionID}`
}
export function stressDraftHref(draftID: string) {
return `/new-session?draftId=${encodeURIComponent(draftID)}`
}
function stressServer() {
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
} }

View File

@ -0,0 +1,32 @@
import { expect, test } from "bun:test"
import { summarizeFirstNavigation } from "../timeline/first-navigation-metrics"
test("reports blank frames before first destination and stable paint", () => {
expect(
summarizeFirstNavigation([
{ observedAtMs: 16, source: true, destination: false, content: true },
{ observedAtMs: 32, source: false, destination: false, content: false },
{ observedAtMs: 48, source: false, destination: true, content: true },
{ observedAtMs: 64, source: false, destination: true, content: true },
{ observedAtMs: 80, source: false, destination: true, content: true },
]),
).toEqual({
samples: 5,
firstDestinationObservedMs: 48,
stableDestinationObservedMs: 80,
sourceSamples: 1,
blankSamples: 1,
unknownSamples: 0,
destinationSamples: 3,
})
})
test("does not report stability for interrupted destination frames", () => {
expect(
summarizeFirstNavigation([
{ observedAtMs: 16, source: false, destination: true, content: true },
{ observedAtMs: 32, source: false, destination: false, content: true },
{ observedAtMs: 48, source: false, destination: true, content: true },
]).stableDestinationObservedMs,
).toBeNull()
})

View File

@ -0,0 +1,34 @@
import { expect, test } from "bun:test"
import { summarizeNavigationMilestones } from "../timeline/navigation-milestones"
test("reports first and stable paint for each navigation milestone", () => {
expect(
summarizeNavigationMilestones([
{ observedAtMs: 16, milestones: { content: false, tab: false } },
{ observedAtMs: 32, milestones: { content: true, tab: false } },
{ observedAtMs: 48, milestones: { content: true, tab: true } },
{ observedAtMs: 64, milestones: { content: true, tab: true } },
{ observedAtMs: 80, milestones: { content: true, tab: true } },
]),
).toEqual({
samples: 5,
milestones: {
content: { firstObservedMs: 32, stableObservedMs: 64 },
tab: { firstObservedMs: 48, stableObservedMs: 80 },
},
all: { firstObservedMs: 48, stableObservedMs: 80 },
})
})
test("reports missing stability when a milestone appears in the final samples", () => {
expect(
summarizeNavigationMilestones([
{ observedAtMs: 16, milestones: { content: false } },
{ observedAtMs: 32, milestones: { content: true } },
]),
).toEqual({
samples: 2,
milestones: { content: { firstObservedMs: 32, stableObservedMs: null } },
all: { firstObservedMs: 32, stableObservedMs: null },
})
})

View File

@ -0,0 +1,10 @@
import { expect, test } from "bun:test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture } from "../timeline/session-timeline-stress.fixture"
import { stressSessionHref } from "../timeline/timeline-test-helpers"
test("builds stress session links for the benchmark server", () => {
expect(stressSessionHref(fixture.sourceID)).toBe(
`/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`,
)
})

View File

@ -57,8 +57,8 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
await page.mouse.move(0, 0) await page.mouse.move(0, 0)
await lineNumber.hover() await lineNumber.hover()
await expect(comment).toBeVisible({ timeout: 500 }) await expect(comment).toBeVisible({ timeout: 500 })
await comment.click({ timeout: 500 })
}).toPass() }).toPass()
await comment.click()
await expect(review.getByRole("textbox")).toBeVisible() await expect(review.getByRole("textbox")).toBeVisible()
}) })

View File

@ -58,7 +58,18 @@ test.describe("smoke: session timeline", () => {
await page.mouse.wheel(0, -120) await page.mouse.wheel(0, -120)
await page.waitForTimeout(20) await page.waitForTimeout(20)
} }
const keys = ["prt_user_text_smoke_0032", "prt_text_2_smoke_0032", "prt_tool_apply_patch_8_smoke_0032"] const keys = await scroller.evaluate((element) => {
const view = element.getBoundingClientRect()
return [...element.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
.filter((row) => {
const rect = row.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((row) => row.dataset.timelinePartId)
.filter((id): id is string => !!id)
.slice(0, 3)
})
expect(keys.length).toBeGreaterThan(0)
const positions = () => const positions = () =>
scroller.evaluate((element, keys) => { scroller.evaluate((element, keys) => {
const top = element.getBoundingClientRect().top const top = element.getBoundingClientRect().top

View File

@ -26,6 +26,8 @@ export interface MockServerConfig {
} }
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>()
let nextCursor = 0
const staticRoutes: Record<string, unknown> = { const staticRoutes: Record<string, unknown> = {
"/provider": config.provider, "/provider": config.provider,
"/path": { "/path": {
@ -68,13 +70,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) { if (messagesMatch) {
const before = url.searchParams.get("before") ?? undefined const token = url.searchParams.get("before") ?? undefined
const before = token ? cursors.get(token) : undefined
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" }) config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const limit = Number(url.searchParams.get("limit") ?? 80) const limit = Number(url.searchParams.get("limit") ?? 80)
const pageData = config.pageMessages(messagesMatch[1], limit, before) const pageData = config.pageMessages(messagesMatch[1], limit, before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" }) config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined) if (!pageData.cursor) return json(route, pageData.items)
const cursor = `cursor_${++nextCursor}`
cursors.set(cursor, pageData.cursor)
return json(route, pageData.items, { "x-next-cursor": cursor })
} }
if (url.port === targetPort && targetPort !== appPort) return json(route, {}) if (url.port === targetPort && targetPort !== appPort) return json(route, {})
@ -82,9 +89,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}) })
} }
function json(route: Route, body: unknown, headers?: Record<string, string>) { function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
return route.fulfill({ return route.fulfill({
status: 200, status,
contentType: "application/json", contentType: "application/json",
headers: { headers: {
"access-control-allow-origin": "*", "access-control-allow-origin": "*",

View File

@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
type Component, type Component,
@ -32,7 +32,7 @@ import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync" import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global" import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
@ -53,13 +53,12 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route" import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route"
const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome }))) import Session from "@/pages/session"
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome }))) import { NewHome, LegacyHome } from "@/pages/home"
const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session")) const NewSession = lazy(() => import("@/pages/new-session"))
const SessionRoute = Object.assign( const SessionRoute = () => {
() => {
const settings = useSettings() const settings = useSettings()
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
@ -85,12 +84,9 @@ const SessionRoute = Object.assign(
<Session /> <Session />
</SessionProviders> </SessionProviders>
) )
}, }
{ preload: Session.preload },
)
const TargetSessionRoute = Object.assign( const TargetSessionRoute = () => {
() => {
const params = useParams<{ serverKey: string; id: string }>() const params = useParams<{ serverKey: string; id: string }>()
const server = useServer() const server = useServer()
const conn = createMemo(() => { const conn = createMemo(() => {
@ -107,33 +103,39 @@ const TargetSessionRoute = Object.assign(
</ServerSDKProvider> </ServerSDKProvider>
</Show> </Show>
) )
}, }
{ preload: Session.preload },
)
function ResolvedTargetSessionRoute() { function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>() const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings() const settings = useSettings()
const tabs = useTabs() const tabs = useTabs()
const global = useGlobal()
const serverSDK = useServerSDK() const serverSDK = useServerSDK()
const serverKey = createMemo(() => requireServerKey(params.serverKey)) const serverKey = createMemo(() => requireServerKey(params.serverKey))
const resolved = useQuery(() => ({ const placement = createMemo(() => global.sessionPlacement.get(serverKey(), params.id))
queryKey: [serverSDK().scope, "session-route", params.id] as const, const [resolved] = createResource(
queryFn: async () => { () => {
const session = (await serverSDK().client.session.get({ sessionID: params.id })).data! if (placement()) return
const root = await rootSession(session, (sessionID) => return { id: params.id, sdk: serverSDK() }
serverSDK()
.client.session.get({ sessionID })
.then((result) => result.data!),
)
return { session, rootID: root.id }
}, },
})) async ({ id, sdk }) => {
const directory = createMemo<string | undefined>((prev) => prev ?? resolved.data?.session.directory) const session = (await sdk.client.session.get({ sessionID: id })).data!
const root = await rootSession(session, (sessionID) =>
sdk.client.session.get({ sessionID }).then((result) => result.data!),
)
return global.sessionPlacement.set({
server: serverKey(),
leafID: session.id,
rootID: root.id,
directory: session.directory,
})
},
)
const directory = createMemo(() => placement()?.directory ?? resolved()?.directory)
const targetDirectory = () => directory()! const targetDirectory = () => directory()!
createEffect(() => { createEffect(() => {
const current = resolved.data const current = placement() ?? resolved()
if (!current) return if (!current) return
tabs.addSessionTab({ tabs.addSessionTab({
server: serverKey(), server: serverKey(),
@ -151,9 +153,7 @@ function ResolvedTargetSessionRoute() {
> >
<SDKProvider directory={targetDirectory}> <SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}> <DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<Show when={resolved.data && !resolved.isPlaceholderData}>
<TargetSessionPage /> <TargetSessionPage />
</Show>
</DirectoryDataProvider> </DirectoryDataProvider>
</SDKProvider> </SDKProvider>
</Show> </Show>

View File

@ -2,6 +2,7 @@ import {
createEffect, createEffect,
createMemo, createMemo,
createResource, createResource,
createRoot,
createSignal, createSignal,
For, For,
Match, Match,
@ -512,25 +513,64 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
) )
} }
const sdk = createMemo(() => { const serverCtx = createMemo(() => {
const conn = server.list.find((s) => ServerConnection.key(s) === tab.server) const conn = server.list.find((item) => ServerConnection.key(item) === tab.server)
if (!conn) return null return conn ? global.createServerCtx(conn) : undefined
const { sdk } = global.createServerCtx(conn)
return sdk
}) })
const [session] = createResource( const sdk = createMemo(() => serverCtx()?.sdk ?? null)
const cachedSession = createMemo(() => {
const placement = global.sessionPlacement.get(tab.server, tab.sessionId)
const ctx = serverCtx()
if (!placement || !ctx) return
return ctx.sync
.child(placement.directory, { bootstrap: false })[0]
.session.find((session) => session.id === tab.sessionId)
})
const [loadedSession] = createResource(
() => { () => {
if (cachedSession()) return null
const id = tab.sessionId const id = tab.sessionId
const _sdk = sdk() const ctx = serverCtx()
if (!_sdk) return null return ctx ? { id, ctx } : null
return { id, sdk: _sdk }
}, },
({ id, sdk }) => ({ id, ctx }) =>
sdk.client.session ctx.sdk.client.session
.get({ sessionID: id }) .get({ sessionID: id })
.then((x) => x.data) .then((x) => {
const session = x.data
if (!session) return
if (!session.parentID)
global.sessionPlacement.set({
server: tab.server,
leafID: session.id,
rootID: session.id,
directory: session.directory,
})
return session
})
.catch(() => undefined), .catch(() => undefined),
) )
const session = createMemo(() => cachedSession() ?? loadedSession())
let prefetched = false
createEffect(() => {
const ctx = serverCtx()
const sess = session()
if (!ctx || !sess || prefetched) return
prefetched = true
createRoot((dispose) => {
try {
void ctx.sync
.createDirSyncContext(sess.directory)
.session.sync(sess.id)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
createEffect(() => { createEffect(() => {
if (tab.type !== "session") return if (tab.type !== "session") return

View File

@ -187,7 +187,7 @@ export const createDirSyncContext = (
return serverSync.child(directory) return serverSync.child(directory)
} }
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80 const initialMessagePageSize = 2
const historyMessagePageSize = 200 const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>() const inflightDiff = new Map<string, Promise<void>>()

View File

@ -8,11 +8,13 @@ import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web" import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { createSessionPlacementStore } from "@/utils/session-placement"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global", name: "Global",
init: () => { init: () => {
const server = useServer() const server = useServer()
const sessionPlacement = createSessionPlacementStore()
const serverHealth = useServerHealth( const serverHealth = useServerHealth(
() => server.list, () => server.list,
() => true, () => true,
@ -85,6 +87,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
}, },
}, },
}, },
sessionPlacement,
createServerCtx(conn: ServerConnection.Any) { createServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn) return ensureServerCtx(conn)
}, },

View File

@ -11,6 +11,7 @@ import { decode64 } from "@/utils/base64"
import { Schema } from "effect" import { Schema } from "effect"
import type { ServerConnection } from "@/context/server" import type { ServerConnection } from "@/context/server"
import { sessionHref } from "@/utils/session-route" import { sessionHref } from "@/utils/session-route"
import { useGlobal } from "@/context/global"
export function DirectoryDataProvider( export function DirectoryDataProvider(
props: ParentProps<{ props: ParentProps<{
@ -23,6 +24,7 @@ export function DirectoryDataProvider(
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const global = useGlobal()
const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory) const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory)
const slug = createMemo(() => base64Encode(directory())) const slug = createMemo(() => base64Encode(directory()))
const href = (sessionID: string) => { const href = (sessionID: string) => {
@ -52,7 +54,11 @@ export function DirectoryDataProvider(
<DataProvider <DataProvider
data={sync().data} data={sync().data}
directory={directory()} directory={directory()}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))} onNavigateToSession={(sessionID: string) => {
const server = props.server?.()
if (server && params.id) global.sessionPlacement.inherit(server, params.id, sessionID)
navigate(href(sessionID))
}}
onSessionHref={href} onSessionHref={href}
> >
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>

View File

@ -3,6 +3,7 @@ import {
batch, batch,
createEffect, createEffect,
createMemo, createMemo,
createRoot,
For, For,
Match, Match,
on, on,
@ -58,6 +59,8 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row" import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health" import { type ServerHealth } from "@/utils/server-health"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useMarked } from "@opencode-ai/ui/context/marked"
import { preloadMarkdown } from "@opencode-ai/ui/markdown-cache"
const HOME_SESSION_LIMIT = 64 const HOME_SESSION_LIMIT = 64
const HOME_ROW_LAYOUT = const HOME_ROW_LAYOUT =
@ -134,9 +137,10 @@ export function NewHome() {
const server = useServer() const server = useServer()
const language = useLanguage() const language = useLanguage()
const global = useGlobal() const global = useGlobal()
const tabs = useTabs()
const command = useCommand() const command = useCommand()
const notification = useNotification() const notification = useNotification()
const tabs = useTabs() const marked = useMarked()
let focusSessionSearch: (() => void) | undefined let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({ const [state, setState] = createStore({
search: "", search: "",
@ -199,6 +203,41 @@ export function NewHome() {
}) })
const searchOpen = createMemo(() => state.searchFocused && search().length > 0) const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language)) const groups = createMemo(() => groupSessions(records(), language))
const prefetched = new Set<string>()
createEffect(() => {
const ctx = focusedServerCtx()
if (!ctx) return
records()
.slice(0, 2)
.forEach((record) => {
const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
if (prefetched.has(key)) return
prefetched.add(key)
createRoot((dispose) => {
try {
const directory = ctx.sync.createDirSyncContext(record.session.directory)
void directory.session
.sync(record.session.id)
.then(() => {
const store = ctx.sync.child(record.session.directory)[0]
return Promise.all(
(store.message[record.session.id] ?? []).flatMap((message) =>
(store.part[message.id] ?? []).flatMap((part) => {
if (part.type !== "text" || !part.text) return []
return preloadMarkdown(part.text, part.id, marked)
}),
),
)
})
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
})
function setSelection(next: HomeProjectSelection) { function setSelection(next: HomeProjectSelection) {
batch(() => { batch(() => {
@ -304,6 +343,12 @@ export function NewHome() {
if (!conn) return if (!conn) return
const directory = project?.worktree ?? session.directory const directory = project?.worktree ?? session.directory
const ctx = global.createServerCtx(conn) const ctx = global.createServerCtx(conn)
global.sessionPlacement.set({
server: ServerConnection.key(conn),
leafID: session.id,
rootID: session.id,
directory: session.directory,
})
ctx.projects.open(directory) ctx.projects.open(directory)
ctx.projects.touch(directory) ctx.projects.touch(directory)
startTransition(() => { startTransition(() => {

View File

@ -287,7 +287,7 @@ export default function Page() {
}) })
} }
return key return key
}, sessionKey()) })
let reviewFrame: number | undefined let reviewFrame: number | undefined
let todoFrame: number | undefined let todoFrame: number | undefined
@ -693,6 +693,7 @@ export default function Page() {
} }
createEffect(() => { createEffect(() => {
if (!sync().project) return
const list = changesOptions() const list = changesOptions()
if (list.includes(store.changes)) return if (list.includes(store.changes)) return
const next = list[0] const next = list[0]
@ -1128,13 +1129,34 @@ export default function Page() {
let captureHistoryAnchor = () => {} let captureHistoryAnchor = () => {}
let restoreHistoryAnchor = (_done: boolean) => {} let restoreHistoryAnchor = (_done: boolean) => {}
const loadOlder = () => let historyRequest = false
timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor }) let historyContinuationFrame: number | undefined
const loadOlder = async () => {
if (historyRequest || historyLoading()) return
historyRequest = true
const before = timeline.messages().length
try {
await timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
} finally {
historyRequest = false
}
if (timeline.messages().length <= before) return
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
historyContinuationFrame = requestAnimationFrame(() => {
historyContinuationFrame = undefined
onHistoryScroll()
})
}
const onHistoryScroll = () => { const onHistoryScroll = () => {
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return
void loadOlder() void loadOlder()
} }
onCleanup(() => {
if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
})
fill = () => { fill = () => {
if (fillFrame !== undefined) return if (fillFrame !== undefined) return

View File

@ -15,37 +15,29 @@ describe("timeline model", () => {
expect(selectVisibleUserMessages(users)).toBe(users) expect(selectVisibleUserMessages(users)).toBe(users)
}) })
test("loads pages until a visible user turn is added", async () => { test("loads exactly one opaque cursor page", async () => {
let loaded = 10
let visible = 2
let calls = 0 let calls = 0
const anchors: Array<string | boolean> = [] const anchors: Array<string | boolean> = []
await loadOlderTimeline({ await loadOlderTimeline({
sessionID: () => "ses_test", sessionID: () => "ses_test",
loaded: () => loaded,
visible: () => visible,
more: () => true, more: () => true,
loading: () => false, loading: () => false,
loadMore: async () => { loadMore: async () => {
calls += 1 calls += 1
loaded += 3
if (calls === 2) visible += 1
}, },
before: () => anchors.push("before"), before: () => anchors.push("before"),
after: (done) => anchors.push("after", done), after: (done) => anchors.push("after", done),
}) })
expect(calls).toBe(2) expect(calls).toBe(1)
expect(anchors).toEqual(["before", "after", false, "after", true]) expect(anchors).toEqual(["before", "after", true])
}) })
test("stops when a page adds no raw messages", async () => { test("stops when a page adds no raw messages", async () => {
let calls = 0 let calls = 0
await loadOlderTimeline({ await loadOlderTimeline({
sessionID: () => "ses_test", sessionID: () => "ses_test",
loaded: () => 10,
visible: () => 2,
more: () => true, more: () => true,
loading: () => false, loading: () => false,
loadMore: async () => { loadMore: async () => {
@ -62,8 +54,6 @@ describe("timeline model", () => {
await loadOlderTimeline({ await loadOlderTimeline({
sessionID: () => sessionID, sessionID: () => sessionID,
loaded: () => 10,
visible: () => 2,
more: () => true, more: () => true,
loading: () => false, loading: () => false,
loadMore: async () => { loadMore: async () => {
@ -83,8 +73,6 @@ describe("timeline model", () => {
await expect( await expect(
loadOlderTimeline({ loadOlderTimeline({
sessionID: () => "ses_test", sessionID: () => "ses_test",
loaded: () => 10,
visible: () => 2,
more: () => true, more: () => true,
loading: () => false, loading: () => false,
loadMore: async () => { loadMore: async () => {

View File

@ -74,8 +74,6 @@ export function createTimelineModel(input: {
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => { const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
return loadOlderTimeline({ return loadOlderTimeline({
sessionID: input.sessionID, sessionID: input.sessionID,
loaded: () => messages().length,
visible: () => visibleUserMessages().length,
more, more,
loading, loading,
loadMore: (sessionID) => sync().session.history.loadMore(sessionID), loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
@ -115,8 +113,6 @@ export function selectVisibleUserMessages(messages: UserMessage[], revertMessage
export async function loadOlderTimeline(input: { export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined> sessionID: Accessor<string | undefined>
loaded: Accessor<number>
visible: Accessor<number>
more: Accessor<boolean> more: Accessor<boolean>
loading: Accessor<boolean> loading: Accessor<boolean>
loadMore: (sessionID: string) => Promise<void> loadMore: (sessionID: string) => Promise<void>
@ -126,23 +122,11 @@ export async function loadOlderTimeline(input: {
const id = input.sessionID() const id = input.sessionID()
if (!id || !input.more() || input.loading()) return if (!id || !input.more() || input.loading()) return
// A history page may contain only assistant messages or user turns hidden by a revert boundary.
const beforeVisible = input.visible()
let loaded = input.loaded()
input.before?.() input.before?.()
while (true) {
await input.loadMore(id).catch((error) => { await input.loadMore(id).catch((error) => {
if (input.sessionID() === id) input.after?.(true) if (input.sessionID() === id) input.after?.(true)
throw error throw error
}) })
if (input.sessionID() !== id) return if (input.sessionID() !== id) return
input.after?.(true)
const nextLoaded = input.loaded()
const growth = input.visible() - beforeVisible
const raw = nextLoaded - loaded
loaded = nextLoaded
const done = growth > 0 || raw <= 0 || !input.more()
input.after?.(done)
if (done) return
}
} }

View File

@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { createSessionPlacementStore } from "./session-placement"
describe("session placement", () => {
const local = ServerConnection.Key.make("http://localhost:4096")
const remote = ServerConnection.Key.make("https://example.com")
test("aliases a leaf and root without crossing servers", () => {
const store = createSessionPlacementStore()
store.set({ server: local, leafID: "child", rootID: "root", directory: "/repo" })
store.set({ server: remote, leafID: "child", rootID: "other", directory: "/remote" })
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(local, "root")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(remote, "child")).toEqual({ rootID: "other", directory: "/remote" })
})
test("inherits known placement for in-app child navigation", () => {
const store = createSessionPlacementStore()
store.set({ server: local, leafID: "parent", rootID: "root", directory: "/repo" })
expect(store.inherit(local, "parent", "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.get(local, "child")).toEqual({ rootID: "root", directory: "/repo" })
expect(store.inherit(local, "missing", "unknown")).toBeUndefined()
})
test("bounds retained placement aliases", () => {
const store = createSessionPlacementStore()
for (let index = 0; index < 300; index++) {
store.set({ server: local, leafID: `leaf-${index}`, rootID: `root-${index}`, directory: `/repo/${index}` })
}
expect(store.size()).toBeLessThanOrEqual(256)
expect(store.get(local, "leaf-0")).toBeUndefined()
expect(store.get(local, "leaf-299")).toEqual({ rootID: "root-299", directory: "/repo/299" })
})
})

View File

@ -0,0 +1,41 @@
import { ServerConnection } from "@/context/server"
export type SessionPlacement = {
rootID: string
directory: string
}
export function createSessionPlacementStore() {
const placements = new Map<string, SessionPlacement>()
const limit = 256
const key = (server: ServerConnection.Key, sessionID: string) => `${server}\0${sessionID}`
const write = (id: string, placement: SessionPlacement) => {
placements.delete(id)
placements.set(id, placement)
while (placements.size > limit) placements.delete(placements.keys().next().value!)
}
return {
get(server: ServerConnection.Key, sessionID: string) {
const id = key(server, sessionID)
const placement = placements.get(id)
if (placement) write(id, placement)
return placement
},
set(input: SessionPlacement & { server: ServerConnection.Key; leafID: string }) {
const placement = { rootID: input.rootID, directory: input.directory }
write(key(input.server, input.leafID), placement)
write(key(input.server, input.rootID), placement)
return placement
},
inherit(server: ServerConnection.Key, sourceID: string, leafID: string) {
const placement = placements.get(key(server, sourceID))
if (!placement) return
write(key(server, leafID), placement)
return placement
},
size() {
return placements.size
},
}
}

View File

@ -0,0 +1,78 @@
import { checksum } from "@opencode-ai/core/util/encode"
import DOMPurify from "dompurify"
import { project } from "./markdown-stream"
export type MarkdownCacheEntry = {
raw: string
hash: string
html: string
}
const max = 200
const cache = new Map<string, MarkdownCacheEntry>()
const config = {
USE_PROFILES: { html: true, mathMl: true },
SANITIZE_NAMED_PROPS: true,
FORBID_TAGS: ["style"],
FORBID_CONTENTS: ["style", "script"],
ADD_TAGS: ["svg", "path"],
ADD_ATTR: ["d", "viewBox", "preserveAspectRatio", "xmlns", "target"],
}
if (typeof window !== "undefined" && DOMPurify.isSupported) {
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
if (!(node instanceof HTMLAnchorElement)) return
if (node.target !== "_blank") return
const rel = node.getAttribute("rel") ?? ""
const set = new Set(rel.split(/\s+/).filter(Boolean))
set.add("noopener")
set.add("noreferrer")
node.setAttribute("rel", Array.from(set).join(" "))
})
}
export function sanitizeMarkdown(html: string) {
if (!DOMPurify.isSupported) return ""
return DOMPurify.sanitize(html, config)
}
export function getCachedMarkdown(key: string) {
return cache.get(key)
}
export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
cache.delete(key)
cache.set(key, value)
if (cache.size <= max) return
const first = cache.keys().next().value
if (!first) return
cache.delete(first)
}
export async function preloadMarkdown(
text: string,
cacheKey: string,
parser: { parse(text: string): string | Promise<string> },
) {
await Promise.all(
project(undefined, text, false).blocks.map(async (block, index) => {
if (block.mode === "code") return
const key = `${cacheKey}:${index}:${block.mode}`
const cached = getCachedMarkdown(key)
if (cached?.raw === block.raw) {
touchCachedMarkdown(key, cached)
return
}
const hash = checksum(block.raw)
if (!hash) return
touchCachedMarkdown(key, {
raw: block.raw,
hash,
html: sanitizeMarkdown(await Promise.resolve(parser.parse(block.src))),
})
}),
)
}

View File

@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { preloadMarkdown } from "./markdown-cache"
test("preloads completed markdown into the render cache", async () => {
const parsed: string[] = []
const parser = {
parse(text: string) {
parsed.push(text)
return `<p>${text}</p>`
},
}
const key = `markdown-preload-${crypto.randomUUID()}`
await preloadMarkdown("prepared response", key, parser)
await preloadMarkdown("prepared response", key, parser)
expect(parsed).toEqual(["prepared response"])
})

View File

@ -1,6 +1,5 @@
import { useMarked } from "../context/marked" import { useMarked } from "../context/marked"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import DOMPurify from "dompurify"
import morphdom from "morphdom" import morphdom from "morphdom"
import { checksum } from "@opencode-ai/core/util/encode" import { checksum } from "@opencode-ai/core/util/encode"
import { import {
@ -25,15 +24,10 @@ import {
} from "./markdown-worker" } from "./markdown-worker"
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol" import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state" import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
type Entry = {
raw: string
hash: string
html: string
}
type RenderedBlock = type RenderedBlock =
| (Entry & { key: string; mode: Exclude<Block["mode"], "code"> }) | (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
| { | {
key: string key: string
mode: "code" mode: "code"
@ -51,42 +45,13 @@ type RenderResult = {
blocks: RenderedBlock[] blocks: RenderedBlock[]
} }
const max = 200
const cache = new Map<string, Entry>()
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>() const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
if (typeof window !== "undefined" && DOMPurify.isSupported) {
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
if (!(node instanceof HTMLAnchorElement)) return
if (node.target !== "_blank") return
const rel = node.getAttribute("rel") ?? ""
const set = new Set(rel.split(/\s+/).filter(Boolean))
set.add("noopener")
set.add("noreferrer")
node.setAttribute("rel", Array.from(set).join(" "))
})
}
const config = {
USE_PROFILES: { html: true, mathMl: true },
SANITIZE_NAMED_PROPS: true,
FORBID_TAGS: ["style"],
FORBID_CONTENTS: ["style", "script"],
ADD_TAGS: ["svg", "path"],
ADD_ATTR: ["d", "viewBox", "preserveAspectRatio", "xmlns", "target"],
}
const iconPaths = { const iconPaths = {
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>', copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>', check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
} }
function sanitize(html: string) {
if (!DOMPurify.isSupported) return ""
return DOMPurify.sanitize(html, config)
}
function escape(text: string) { function escape(text: string) {
return text return text
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@ -283,17 +248,6 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
} }
} }
function touch(key: string, value: Entry) {
cache.delete(key)
cache.set(key, value)
if (cache.size <= max) return
const first = cache.keys().next().value
if (!first) return
cache.delete(first)
}
function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult { function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult {
if (!text) return { text, blocks: [] } if (!text) return { text, blocks: [] }
const base = key ?? checksum(text) const base = key ?? checksum(text)
@ -301,7 +255,7 @@ function initialResult(text: string, key: string | undefined, projection: Projec
const blocks = projection.blocks.flatMap((block, index) => { const blocks = projection.blocks.flatMap((block, index) => {
if (block.mode === "code") return [] if (block.mode === "code") return []
const cacheKey = `${base}:${index}:${block.mode}` const cacheKey = `${base}:${index}:${block.mode}`
const cached = cache.get(cacheKey) const cached = getCachedMarkdown(cacheKey)
if (cached?.raw !== block.raw) return [] if (cached?.raw !== block.raw) return []
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }] return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
}) })
@ -387,16 +341,16 @@ export function Markdown(
} }
if (key) { if (key) {
const cached = cache.get(key) const cached = getCachedMarkdown(key)
if (cached?.raw === block.raw) { if (cached?.raw === block.raw) {
touch(key, cached) touchCachedMarkdown(key, cached)
return { key: blockKey, mode: block.mode, ...cached } return { key: blockKey, mode: block.mode, ...cached }
} }
} }
const hash = checksum(block.raw) const hash = checksum(block.raw)
const safe = sanitize(await Promise.resolve(marked.parse(block.src))) const safe = sanitizeMarkdown(await Promise.resolve(marked.parse(block.src)))
if (key && hash) touch(key, { raw: block.raw, hash, html: safe }) if (key && hash) touchCachedMarkdown(key, { raw: block.raw, hash, html: safe })
return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe } return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
}), }),
) )