fix(app): make session navigation stable and fast (#33569)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
parent
abcfeb380b
commit
a4551a94b4
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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}"]`
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
@ -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}"]`
|
||||||
|
}
|
||||||
123
packages/app/e2e/performance/timeline/navigation-milestones.ts
Normal file
123
packages/app/e2e/performance/timeline/navigation-milestones.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -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] })
|
||||||
|
})
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"}`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
})
|
||||||
@ -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 },
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": "*",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>>()
|
||||||
|
|||||||
@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
38
packages/app/src/utils/session-placement.test.ts
Normal file
38
packages/app/src/utils/session-placement.test.ts
Normal 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" })
|
||||||
|
})
|
||||||
|
})
|
||||||
41
packages/app/src/utils/session-placement.ts
Normal file
41
packages/app/src/utils/session-placement.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
78
packages/ui/src/components/markdown-cache.tsx
Normal file
78
packages/ui/src/components/markdown-cache.tsx
Normal 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))),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
18
packages/ui/src/components/markdown-preload.test.ts
Normal file
18
packages/ui/src/components/markdown-preload.test.ts
Normal 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"])
|
||||||
|
})
|
||||||
@ -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, "&")
|
.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 {
|
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 }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user