diff --git a/packages/app/e2e/performance/README.md b/packages/app/e2e/performance/README.md index afa3108d5cc2..ce868d573bb7 100644 --- a/packages/app/e2e/performance/README.md +++ b/packages/app/e2e/performance/README.md @@ -18,6 +18,8 @@ bun run test:bench The suite contains: - 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 - streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics diff --git a/packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts b/packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts new file mode 100644 index 000000000000..a07e8f3d89fb --- /dev/null +++ b/packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts @@ -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[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}"]` +} diff --git a/packages/app/e2e/performance/timeline/first-navigation-metrics.ts b/packages/app/e2e/performance/timeline/first-navigation-metrics.ts new file mode 100644 index 000000000000..7c4a7d1f161e --- /dev/null +++ b/packages/app/e2e/performance/timeline/first-navigation-metrics.ts @@ -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, + } +} diff --git a/packages/app/e2e/performance/timeline/first-navigation-probe.ts b/packages/app/e2e/performance/timeline/first-navigation-probe.ts new file mode 100644 index 000000000000..0fa3af6d84d8 --- /dev/null +++ b/packages/app/e2e/performance/timeline/first-navigation-probe.ts @@ -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 + }, +) { + await page.evaluate( + ({ href, destinationPath, sourceSelector, destinationSelector, contentSelector }) => { + const samples: FirstNavigationSample[] = [] + let started: number | undefined + let running = true + const visible = (selector: string) => + [...document.querySelectorAll(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 } +} diff --git a/packages/app/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts b/packages/app/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts new file mode 100644 index 000000000000..9b74c7d6bfd4 --- /dev/null +++ b/packages/app/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts @@ -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(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[0], sessionIDs: string[]) { + await mockStressTimeline(page) + await installTimelineSettings(page) + await installStressSessionTabs(page, { sessionIDs }) +} + +function messageSelector(id: string) { + return `[data-message-id="${id}"]` +} diff --git a/packages/app/e2e/performance/timeline/navigation-milestones.ts b/packages/app/e2e/performance/timeline/navigation-milestones.ts new file mode 100644 index 000000000000..6f003d2443e8 --- /dev/null +++ b/packages/app/e2e/performance/timeline/navigation-milestones.ts @@ -0,0 +1,123 @@ +import type { Page } from "@playwright/test" + +export type NavigationMilestoneSample = { + observedAtMs: number + milestones: Record +} + +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 + navigate: () => Promise + }, +) { + await page.evaluate(({ triggerSelector, milestones }) => { + const samples: NavigationMilestoneSample[] = [] + const streaks = new Map() + const marked = new Set() + let started: number | undefined + let running = true + const visible = (selector: string) => + [...document.querySelectorAll(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 } +} diff --git a/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts b/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts index 741084751f1d..051b29d0cd9f 100644 --- a/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts +++ b/packages/app/e2e/performance/timeline/session-tab-flash.spec.ts @@ -47,3 +47,21 @@ benchmark("samples cached session repaint after the click", async ({ page, repor report(compressCachedRepaintTrace(result)) expect(result.samples.length).toBeGreaterThan(0) }) + +benchmark("prefetches every open session tab", async ({ page, report }) => { + const prefetched = new Set() + 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] }) +}) diff --git a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts index e5c353e4cd0d..7017752f8285 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts @@ -23,6 +23,7 @@ const words = [ const sourceID = "ses_smoke_source" const targetID = "ses_smoke_target" +const childID = "ses_smoke_child" const directory = "C:/OpenCode/SmokeProject" const projectID = "proj_smoke_timeline" const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } @@ -126,9 +127,11 @@ function toolPart( tool: string, input: Record, outputLength = 160, + metadataOverride?: Record, ): MessagePart { const metadata = - tool === "apply_patch" + metadataOverride ?? + (tool === "apply_patch" ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } : tool === "edit" || tool === "write" ? { @@ -138,7 +141,7 @@ function toolPart( } : tool === "question" ? { answers: [["Proceed"], ["Keep sample output"]] } - : {} + : {}) return { id: id(`prt_tool_${tool}_${partIndex}`, index), type: "tool", @@ -244,7 +247,25 @@ function turn(index: number): Message[] { const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() const sourceMessages = Array.from({ length: 12 }, (_, index) => [ 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() function renderable(part: MessagePart) { @@ -298,19 +319,34 @@ export const fixture = { version: "dev", 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, targetID, - messages: { [sourceID]: sourceMessages, [targetID]: targetMessages }, + childID, + messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages }, expected: { sourceTitle: "Uncommitted changes inquiry", targetTitle: "Example Game: sample jump movement & sample physics analysis", + childTitle: "Inspect child navigation", sourceMessageIDs: sourceMessages .filter((message) => message.info.role === "user") .map((message) => message.info.id), targetMessageIDs: targetMessages .filter((message) => message.info.role === "user") .map((message) => message.info.id), + childMessageIDs: childMessages + .filter((message) => message.info.role === "user") + .map((message) => message.info.id), targetPartIDs: targetMessages.flatMap((message) => orderedParts(message) .filter(renderable) diff --git a/packages/app/e2e/performance/timeline/timeline-test-helpers.ts b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts index dc7e17307189..b66a6f2b254f 100644 --- a/packages/app/e2e/performance/timeline/timeline-test-helpers.ts +++ b/packages/app/e2e/performance/timeline/timeline-test-helpers.ts @@ -1,7 +1,7 @@ import type { Page } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" 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) { await page.addInitScript(() => { @@ -9,6 +9,7 @@ export async function installTimelineSettings(page: Page) { "settings.v3", JSON.stringify({ general: { + newLayoutDesigns: true, editToolPartsExpanded: true, shellToolPartsExpanded: 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, { sessions: fixture.sessions, provider: fixture.provider, directory: fixture.directory, project: fixture.project, - pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), + pageMessages, + onMessages: input?.onMessages, }) } -export async function installStressSessionTabs(page: Page) { - const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) { + const server = stressServer() await page.addInitScript( - ({ directory, sourceID, targetID, dirBase64, server }) => { + ({ directory, sessionIDs, dirBase64, server, draftID }) => { localStorage.setItem( "opencode.global.dat:server", JSON.stringify({ @@ -43,25 +48,36 @@ export async function installStressSessionTabs(page: Page) { localStorage.setItem( "opencode.global.dat:tabs", JSON.stringify( - [sourceID, targetID].map((sessionId) => ({ - type: "session", - server, - dirBase64, - sessionId, - })), + [ + ...sessionIDs.map((sessionId) => ({ + type: "session", + server, + dirBase64, + sessionId, + })), + ...(draftID ? [{ type: "draft", draftID, server, directory }] : []), + ], ), ) }, { directory: fixture.directory, - sourceID: fixture.sourceID, - targetID: fixture.targetID, + sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID], dirBase64: base64Encode(fixture.directory), server, + draftID: input?.draftID, }, ) } 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"}` } diff --git a/packages/app/e2e/performance/unit/first-navigation-metrics.test.ts b/packages/app/e2e/performance/unit/first-navigation-metrics.test.ts new file mode 100644 index 000000000000..c0e5474d6e91 --- /dev/null +++ b/packages/app/e2e/performance/unit/first-navigation-metrics.test.ts @@ -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() +}) diff --git a/packages/app/e2e/performance/unit/navigation-milestones.test.ts b/packages/app/e2e/performance/unit/navigation-milestones.test.ts new file mode 100644 index 000000000000..e22be67063d2 --- /dev/null +++ b/packages/app/e2e/performance/unit/navigation-milestones.test.ts @@ -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 }, + }) +}) diff --git a/packages/app/e2e/performance/unit/timeline-test-helpers.test.ts b/packages/app/e2e/performance/unit/timeline-test-helpers.test.ts new file mode 100644 index 000000000000..98b661d91bba --- /dev/null +++ b/packages/app/e2e/performance/unit/timeline-test-helpers.test.ts @@ -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}`, + ) +}) diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index dcc355cf0cdb..042f926c537e 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -57,8 +57,8 @@ test("shows a comment button when a line number is hovered", async ({ page }) => await page.mouse.move(0, 0) await lineNumber.hover() await expect(comment).toBeVisible({ timeout: 500 }) + await comment.click({ timeout: 500 }) }).toPass() - await comment.click() await expect(review.getByRole("textbox")).toBeVisible() }) diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 597b3577a712..4df3df43ece1 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -58,7 +58,18 @@ test.describe("smoke: session timeline", () => { await page.mouse.wheel(0, -120) 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("[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 = () => scroller.evaluate((element, keys) => { const top = element.getBoundingClientRect().top diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 248459db962f..b722282e7b65 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -26,6 +26,8 @@ export interface MockServerConfig { } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { + const cursors = new Map() + let nextCursor = 0 const staticRoutes: Record = { "/provider": config.provider, "/path": { @@ -68,13 +70,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) 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" }) if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) const limit = Number(url.searchParams.get("limit") ?? 80) const pageData = config.pageMessages(messagesMatch[1], limit, before) 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, {}) @@ -82,9 +89,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } -function json(route: Route, body: unknown, headers?: Record) { +function json(route: Route, body: unknown, headers?: Record, status = 200) { return route.fulfill({ - status: 200, + status, contentType: "application/json", headers: { "access-control-allow-origin": "*", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 215e077397c9..7152e7a0c965 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" 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 { type Component, @@ -32,7 +32,7 @@ import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" import { ServerSyncProvider } from "@/context/server-sync" -import { GlobalProvider } from "@/context/global" +import { GlobalProvider, useGlobal } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" @@ -53,87 +53,89 @@ import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route" -const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome }))) -const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome }))) -const Session = lazy(() => import("@/pages/session")) +import Session from "@/pages/session" +import { NewHome, LegacyHome } from "@/pages/home" + const NewSession = lazy(() => import("@/pages/new-session")) -const SessionRoute = Object.assign( - () => { - const settings = useSettings() - const params = useParams() - const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() - const sdk = useSDK() - const server = useServer() - const tabs = useTabs() - - if (params.id && settings.general.newLayoutDesigns()) { - return - } +const SessionRoute = () => { + const settings = useSettings() + const params = useParams() + const [search] = useSearchParams<{ draftId?: string; prompt?: string }>() + const sdk = useSDK() + const server = useServer() + const tabs = useTabs() - // When the new layout is enabled, the legacy new-session route (/:dir/session with no id) - // is replaced by a draft at /new-session?draftId=… - createEffect(() => { - if (!settings.general.newLayoutDesigns()) return - if (params.id || search.draftId) return - if (!tabs.ready() || !sdk().directory) return - tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt) - }) + if (params.id && settings.general.newLayoutDesigns()) { + return + } - return ( - - - - ) - }, - { preload: Session.preload }, -) - -const TargetSessionRoute = Object.assign( - () => { - const params = useParams<{ serverKey: string; id: string }>() - const server = useServer() - const conn = createMemo(() => { - const key = requireServerKey(params.serverKey) - return server.list.find((item) => ServerConnection.key(item) === key) - }) + // When the new layout is enabled, the legacy new-session route (/:dir/session with no id) + // is replaced by a draft at /new-session?draftId=… + createEffect(() => { + if (!settings.general.newLayoutDesigns()) return + if (params.id || search.draftId) return + if (!tabs.ready() || !sdk().directory) return + tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt) + }) - return ( - - - - - - - - ) - }, - { preload: Session.preload }, -) + return ( + + + + ) +} + +const TargetSessionRoute = () => { + const params = useParams<{ serverKey: string; id: string }>() + const server = useServer() + const conn = createMemo(() => { + const key = requireServerKey(params.serverKey) + return server.list.find((item) => ServerConnection.key(item) === key) + }) + + return ( + + + + + + + + ) +} function ResolvedTargetSessionRoute() { const params = useParams<{ serverKey: string; id: string }>() const settings = useSettings() const tabs = useTabs() + const global = useGlobal() const serverSDK = useServerSDK() const serverKey = createMemo(() => requireServerKey(params.serverKey)) - const resolved = useQuery(() => ({ - queryKey: [serverSDK().scope, "session-route", params.id] as const, - queryFn: async () => { - const session = (await serverSDK().client.session.get({ sessionID: params.id })).data! + const placement = createMemo(() => global.sessionPlacement.get(serverKey(), params.id)) + const [resolved] = createResource( + () => { + if (placement()) return + return { id: params.id, sdk: serverSDK() } + }, + async ({ id, sdk }) => { + const session = (await sdk.client.session.get({ sessionID: id })).data! const root = await rootSession(session, (sessionID) => - serverSDK() - .client.session.get({ sessionID }) - .then((result) => result.data!), + sdk.client.session.get({ sessionID }).then((result) => result.data!), ) - return { session, rootID: root.id } + return global.sessionPlacement.set({ + server: serverKey(), + leafID: session.id, + rootID: root.id, + directory: session.directory, + }) }, - })) - const directory = createMemo((prev) => prev ?? resolved.data?.session.directory) + ) + const directory = createMemo(() => placement()?.directory ?? resolved()?.directory) const targetDirectory = () => directory()! createEffect(() => { - const current = resolved.data + const current = placement() ?? resolved() if (!current) return tabs.addSessionTab({ server: serverKey(), @@ -151,9 +153,7 @@ function ResolvedTargetSessionRoute() { > - - - + diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 0e5e44295807..ad6d84767ab6 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -2,6 +2,7 @@ import { createEffect, createMemo, createResource, + createRoot, createSignal, For, Match, @@ -512,25 +513,64 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { ) } - const sdk = createMemo(() => { - const conn = server.list.find((s) => ServerConnection.key(s) === tab.server) - if (!conn) return null - const { sdk } = global.createServerCtx(conn) - return sdk + const serverCtx = createMemo(() => { + const conn = server.list.find((item) => ServerConnection.key(item) === tab.server) + return conn ? global.createServerCtx(conn) : undefined }) - 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 _sdk = sdk() - if (!_sdk) return null - return { id, sdk: _sdk } + const ctx = serverCtx() + return ctx ? { id, ctx } : null }, - ({ id, sdk }) => - sdk.client.session + ({ id, ctx }) => + ctx.sdk.client.session .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), ) + 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(() => { if (tab.type !== "session") return diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 97f48fb2230f..1702cfe53d74 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -187,7 +187,7 @@ export const createDirSyncContext = ( return serverSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const initialMessagePageSize = 80 + const initialMessagePageSize = 2 const historyMessagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() diff --git a/packages/app/src/context/global.tsx b/packages/app/src/context/global.tsx index b096daa07983..11f2517b05e7 100644 --- a/packages/app/src/context/global.tsx +++ b/packages/app/src/context/global.tsx @@ -8,11 +8,13 @@ import { createServerSyncContext } from "./server-sync" import { getOwner } from "solid-js/web" import { QueryClient } from "@tanstack/solid-query" import type { ServerScope } from "@/utils/server-scope" +import { createSessionPlacementStore } from "@/utils/session-placement" export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ name: "Global", init: () => { const server = useServer() + const sessionPlacement = createSessionPlacementStore() const serverHealth = useServerHealth( () => server.list, () => true, @@ -85,6 +87,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( }, }, }, + sessionPlacement, createServerCtx(conn: ServerConnection.Any) { return ensureServerCtx(conn) }, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index d9d5a2edc624..73ed1ac2731d 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -11,6 +11,7 @@ import { decode64 } from "@/utils/base64" import { Schema } from "effect" import type { ServerConnection } from "@/context/server" import { sessionHref } from "@/utils/session-route" +import { useGlobal } from "@/context/global" export function DirectoryDataProvider( props: ParentProps<{ @@ -23,6 +24,7 @@ export function DirectoryDataProvider( const navigate = useNavigate() const params = useParams() const sync = useSync() + const global = useGlobal() const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory) const slug = createMemo(() => base64Encode(directory())) const href = (sessionID: string) => { @@ -52,7 +54,11 @@ export function DirectoryDataProvider( 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} > {props.children} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 78a209b9efe4..f4a5071d3118 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -3,6 +3,7 @@ import { batch, createEffect, createMemo, + createRoot, For, Match, on, @@ -58,6 +59,8 @@ import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerHealthIndicator } from "@/components/server/server-row" import { type ServerHealth } from "@/utils/server-health" 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_ROW_LAYOUT = @@ -134,9 +137,10 @@ export function NewHome() { const server = useServer() const language = useLanguage() const global = useGlobal() + const tabs = useTabs() const command = useCommand() const notification = useNotification() - const tabs = useTabs() + const marked = useMarked() let focusSessionSearch: (() => void) | undefined const [state, setState] = createStore({ search: "", @@ -199,6 +203,41 @@ export function NewHome() { }) const searchOpen = createMemo(() => state.searchFocused && search().length > 0) const groups = createMemo(() => groupSessions(records(), language)) + const prefetched = new Set() + + 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) { batch(() => { @@ -304,6 +343,12 @@ export function NewHome() { if (!conn) return const directory = project?.worktree ?? session.directory 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.touch(directory) startTransition(() => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index badc2ae85419..0e26a7e650e7 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -287,7 +287,7 @@ export default function Page() { }) } return key - }, sessionKey()) + }) let reviewFrame: number | undefined let todoFrame: number | undefined @@ -693,6 +693,7 @@ export default function Page() { } createEffect(() => { + if (!sync().project) return const list = changesOptions() if (list.includes(store.changes)) return const next = list[0] @@ -1128,13 +1129,34 @@ export default function Page() { let captureHistoryAnchor = () => {} let restoreHistoryAnchor = (_done: boolean) => {} - const loadOlder = () => - timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor }) + let historyRequest = false + 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 = () => { - if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return + if (historyRequest || historyLoading() || !autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return void loadOlder() } + onCleanup(() => { + if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame) + }) + fill = () => { if (fillFrame !== undefined) return diff --git a/packages/app/src/pages/session/timeline/model.test.ts b/packages/app/src/pages/session/timeline/model.test.ts index 054b81c75a15..09f24ff5ef75 100644 --- a/packages/app/src/pages/session/timeline/model.test.ts +++ b/packages/app/src/pages/session/timeline/model.test.ts @@ -15,37 +15,29 @@ describe("timeline model", () => { expect(selectVisibleUserMessages(users)).toBe(users) }) - test("loads pages until a visible user turn is added", async () => { - let loaded = 10 - let visible = 2 + test("loads exactly one opaque cursor page", async () => { let calls = 0 const anchors: Array = [] await loadOlderTimeline({ sessionID: () => "ses_test", - loaded: () => loaded, - visible: () => visible, more: () => true, loading: () => false, loadMore: async () => { calls += 1 - loaded += 3 - if (calls === 2) visible += 1 }, before: () => anchors.push("before"), after: (done) => anchors.push("after", done), }) - expect(calls).toBe(2) - expect(anchors).toEqual(["before", "after", false, "after", true]) + expect(calls).toBe(1) + expect(anchors).toEqual(["before", "after", true]) }) test("stops when a page adds no raw messages", async () => { let calls = 0 await loadOlderTimeline({ sessionID: () => "ses_test", - loaded: () => 10, - visible: () => 2, more: () => true, loading: () => false, loadMore: async () => { @@ -62,8 +54,6 @@ describe("timeline model", () => { await loadOlderTimeline({ sessionID: () => sessionID, - loaded: () => 10, - visible: () => 2, more: () => true, loading: () => false, loadMore: async () => { @@ -83,8 +73,6 @@ describe("timeline model", () => { await expect( loadOlderTimeline({ sessionID: () => "ses_test", - loaded: () => 10, - visible: () => 2, more: () => true, loading: () => false, loadMore: async () => { diff --git a/packages/app/src/pages/session/timeline/model.ts b/packages/app/src/pages/session/timeline/model.ts index 7154827f907f..f34aacb5f95e 100644 --- a/packages/app/src/pages/session/timeline/model.ts +++ b/packages/app/src/pages/session/timeline/model.ts @@ -74,8 +74,6 @@ export function createTimelineModel(input: { const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => { return loadOlderTimeline({ sessionID: input.sessionID, - loaded: () => messages().length, - visible: () => visibleUserMessages().length, more, loading, loadMore: (sessionID) => sync().session.history.loadMore(sessionID), @@ -115,8 +113,6 @@ export function selectVisibleUserMessages(messages: UserMessage[], revertMessage export async function loadOlderTimeline(input: { sessionID: Accessor - loaded: Accessor - visible: Accessor more: Accessor loading: Accessor loadMore: (sessionID: string) => Promise @@ -126,23 +122,11 @@ export async function loadOlderTimeline(input: { const id = input.sessionID() 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?.() - while (true) { - await input.loadMore(id).catch((error) => { - if (input.sessionID() === id) input.after?.(true) - throw error - }) - if (input.sessionID() !== id) return - - 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 - } + await input.loadMore(id).catch((error) => { + if (input.sessionID() === id) input.after?.(true) + throw error + }) + if (input.sessionID() !== id) return + input.after?.(true) } diff --git a/packages/app/src/utils/session-placement.test.ts b/packages/app/src/utils/session-placement.test.ts new file mode 100644 index 000000000000..248889929f92 --- /dev/null +++ b/packages/app/src/utils/session-placement.test.ts @@ -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" }) + }) +}) diff --git a/packages/app/src/utils/session-placement.ts b/packages/app/src/utils/session-placement.ts new file mode 100644 index 000000000000..95ecc450d711 --- /dev/null +++ b/packages/app/src/utils/session-placement.ts @@ -0,0 +1,41 @@ +import { ServerConnection } from "@/context/server" + +export type SessionPlacement = { + rootID: string + directory: string +} + +export function createSessionPlacementStore() { + const placements = new Map() + 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 + }, + } +} diff --git a/packages/ui/src/components/markdown-cache.tsx b/packages/ui/src/components/markdown-cache.tsx new file mode 100644 index 000000000000..3f8f0259f32c --- /dev/null +++ b/packages/ui/src/components/markdown-cache.tsx @@ -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() +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 }, +) { + 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))), + }) + }), + ) +} diff --git a/packages/ui/src/components/markdown-preload.test.ts b/packages/ui/src/components/markdown-preload.test.ts new file mode 100644 index 000000000000..cba107def2a6 --- /dev/null +++ b/packages/ui/src/components/markdown-preload.test.ts @@ -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 `

${text}

` + }, + } + const key = `markdown-preload-${crypto.randomUUID()}` + + await preloadMarkdown("prepared response", key, parser) + await preloadMarkdown("prepared response", key, parser) + + expect(parsed).toEqual(["prepared response"]) +}) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 78cff8abde43..ee2180385100 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,6 +1,5 @@ import { useMarked } from "../context/marked" import { useI18n } from "../context/i18n" -import DOMPurify from "dompurify" import morphdom from "morphdom" import { checksum } from "@opencode-ai/core/util/encode" import { @@ -25,15 +24,10 @@ import { } from "./markdown-worker" import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol" import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state" - -type Entry = { - raw: string - hash: string - html: string -} +import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache" type RenderedBlock = - | (Entry & { key: string; mode: Exclude }) + | (MarkdownCacheEntry & { key: string; mode: Exclude }) | { key: string mode: "code" @@ -51,42 +45,13 @@ type RenderResult = { blocks: RenderedBlock[] } -const max = 200 -const cache = new Map() const renderedCodeTokens = new WeakMap() -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 = { copy: '', check: '', } -function sanitize(html: string) { - if (!DOMPurify.isSupported) return "" - return DOMPurify.sanitize(html, config) -} - function escape(text: string) { return text .replace(/&/g, "&") @@ -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 { if (!text) return { text, blocks: [] } 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) => { if (block.mode === "code") return [] const cacheKey = `${base}:${index}:${block.mode}` - const cached = cache.get(cacheKey) + const cached = getCachedMarkdown(cacheKey) if (cached?.raw !== block.raw) return [] return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }] }) @@ -387,16 +341,16 @@ export function Markdown( } if (key) { - const cached = cache.get(key) + const cached = getCachedMarkdown(key) if (cached?.raw === block.raw) { - touch(key, cached) + touchCachedMarkdown(key, cached) return { key: blockKey, mode: block.mode, ...cached } } } const hash = checksum(block.raw) - const safe = sanitize(await Promise.resolve(marked.parse(block.src))) - if (key && hash) touch(key, { raw: block.raw, hash, html: safe }) + const safe = sanitizeMarkdown(await Promise.resolve(marked.parse(block.src))) + if (key && hash) touchCachedMarkdown(key, { raw: block.raw, hash, html: safe }) return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe } }), )