diff --git a/packages/app/e2e/perf/perf-probe.spec.ts b/packages/app/e2e/perf/perf-probe.spec.ts index 41871ecc5..a38d3993d 100644 --- a/packages/app/e2e/perf/perf-probe.spec.ts +++ b/packages/app/e2e/perf/perf-probe.spec.ts @@ -202,22 +202,56 @@ async function readPromptText(page: Parameters[0]) { } async function revealCachedSessionMessages(page: Parameters[0], expectedCount: number) { - const messages = page.locator(sessionMessageItemSelector) - if ((await messages.count()) < expectedCount) { + for (let attempt = 0; attempt < 12; attempt += 1) { + const budget = await readTimelineDomBudget(page) + if (budget.totalRows >= expectedCount) return + await page.locator(scrollViewportSelector).first().hover() await page.mouse.wheel(0, -2400) + await markTimelineWheelIntent(page, -2400) await settleFrames(page, 2) await scrollTimelineTo(page, 0) await settleFrames(page, 2) const loadEarlier = page.getByRole("button", { name: /Load earlier messages|加载更早的消息/i }).first() - await expect(loadEarlier).toBeVisible({ timeout: 30_000 }) - await loadEarlier.click() + if (await loadEarlier.isVisible().catch(() => false)) + await loadEarlier.click({ timeout: 1_000 }).catch(() => undefined) + try { + await expect + .poll(async () => (await readTimelineDomBudget(page)).totalRows, { timeout: 1_500 }) + .toBeGreaterThanOrEqual(expectedCount) + return + } catch { + // Continue nudging the cached history window; final assertion below reports failure details. + } } await expect .poll(async () => (await readTimelineDomBudget(page)).totalRows, { timeout: 30_000 }) .toBeGreaterThanOrEqual(expectedCount) } +async function revealCachedSessionMessagesThroughDriver(page: Parameters[0]) { + const event = await readTimelineDriverEvent() + await page.evaluate((event) => { + window.dispatchEvent( + new CustomEvent(event, { + detail: { action: "reveal-cached" }, + }), + ) + }, event) +} + +async function readTimelineDriverEvent() { + try { + const timeline = await import("../../src/testing/timeline") + return timeline.timelineEvent + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + // The perf workflow copies this harness into the base checkout, where PR-only testing helpers may not exist yet. + if (message.includes("src/testing/timeline")) return "opencode:e2e:timeline" + throw error + } +} + async function scrollTimelineTo(page: Parameters[0], top: number) { const found = await page.evaluate( ({ top, scrollViewportSelector, turnListSelector }) => { @@ -233,6 +267,20 @@ async function scrollTimelineTo(page: Parameters[0], t expect(found).toBe(true) } +async function markTimelineWheelIntent(page: Parameters[0], deltaY: number) { + const found = await page.evaluate( + ({ deltaY, scrollViewportSelector, turnListSelector }) => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return false + viewport.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY })) + return true + }, + { deltaY, scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) + expect(found).toBe(true) +} + async function hoverTimelineScrollLane(page: Parameters[0]) { const box = await page.locator(scrollViewportSelector).first().boundingBox() expect(box).toBeTruthy() @@ -282,12 +330,20 @@ async function revealLongScrollWindow(page: Parameters const budget = await readTimelineDomBudget(page) if (budget.totalRows >= longScrollMinimumAvailableRows) return await page.mouse.wheel(0, -2400) + await markTimelineWheelIntent(page, -2400) await settleFrames(page, 2) await scrollTimelineTo(page, 0) await settleFrames(page, 2) + try { + await expect + .poll(async () => (await readTimelineDomBudget(page)).totalRows, { timeout: 1_500 }) + .toBeGreaterThanOrEqual(longScrollMinimumAvailableRows) + } catch { + // Continue nudging the history window; final assertion below reports failure details. + } } await expect - .poll(async () => (await readTimelineDomBudget(page)).totalRows, { timeout: 1_000 }) + .poll(async () => (await readTimelineDomBudget(page)).totalRows, { timeout: 10_000 }) .toBeGreaterThanOrEqual(longScrollMinimumAvailableRows) } @@ -300,6 +356,15 @@ async function installComposerPerfDriver(page: Parameters[0]) { + const apply = () => { + const win = window as Window & { __opencode_e2e?: { timeline?: { enabled?: boolean } } } + win.__opencode_e2e = { ...win.__opencode_e2e, timeline: { enabled: true } } + } + await page.addInitScript(apply) + await page.evaluate(apply) +} + async function writeComposerDriver( page: Parameters[0], sessionID: string, @@ -587,6 +652,7 @@ test.describe("PR0.1 perf probe baseline", () => { test("long-session-input-lag emits a 3-run JSON baseline", async ({ page, project }) => { skipUnlessScenario("long-session-input-lag") await installPerfProbe(page) + await installTimelinePerfDriver(page) await applyPerfProfile(page, PERF_PROFILE) await project.open() @@ -597,6 +663,7 @@ test.describe("PR0.1 perf probe baseline", () => { await page.goto(sessionPath(project.directory, session.id)) await expect(page.locator(sessionMessageItemSelector).first()).toBeVisible({ timeout: 30_000 }) await expect(page.locator(promptSelector).first()).toBeVisible({ timeout: 30_000 }) + await revealCachedSessionMessagesThroughDriver(page) await revealCachedSessionMessages(page, TIMELINE_RECOMPUTE_SEED_TURN_COUNT) const prompt = page.locator(promptSelector).first() @@ -825,6 +892,7 @@ test.describe("PR0.1 perf probe baseline", () => { test.setTimeout(180_000) await installComposerPerfDriver(page) await installPerfProbe(page) + await installTimelinePerfDriver(page) await applyPerfProfile(page, PERF_PROFILE) await project.open() @@ -833,6 +901,8 @@ test.describe("PR0.1 perf probe baseline", () => { await withSession(project.sdk, `perf scroll long ${Date.now()}-${run}`, async (session) => { await seedLongScrollSession(project, session.id, run) await page.goto(sessionPath(project.directory, session.id)) + await expect(page.locator(sessionMessageItemSelector).first()).toBeVisible({ timeout: 30_000 }) + await revealCachedSessionMessagesThroughDriver(page) await revealLongScrollWindow(page) const budget = await readTimelineDomBudget(page) expect(budget.totalRows).toBeGreaterThanOrEqual(longScrollMinimumAvailableRows) @@ -845,6 +915,7 @@ test.describe("PR0.1 perf probe baseline", () => { await expandLongScrollTodoDock(page) await hoverTimelineScrollLane(page) + await markTimelineWheelIntent(page, -2400) await scrollTimelineTo(page, 0) await settleFrames(page, 4) const atTop = await readTimelineMetrics(page) @@ -909,6 +980,7 @@ test.describe("PR0.1 perf probe baseline", () => { test("session-timeline-recompute emits a 3-run low-end JSON baseline", async ({ page, project }) => { skipUnlessScenario("session-timeline-recompute") await installPerfProbe(page) + await installTimelinePerfDriver(page) await applyPerfProfile(page, PERF_PROFILE) await project.open() @@ -918,6 +990,8 @@ test.describe("PR0.1 perf probe baseline", () => { await seedTimelineRecomputeSession(project, session.id) await page.goto(sessionPath(project.directory, session.id)) await expect(page.locator(sessionMessageItemSelector).first()).toBeVisible({ timeout: 30_000 }) + await revealCachedSessionMessagesThroughDriver(page) + await revealCachedSessionMessages(page, TIMELINE_RECOMPUTE_SEED_TURN_COUNT) await expect.poll(async () => page.locator(sessionMessageItemSelector).count()).toBeGreaterThanOrEqual(8) await resetPerfProbe(page) await page.locator(scrollViewportSelector).first().hover() diff --git a/packages/app/e2e/perf/runtime-cls-gate.spec.ts b/packages/app/e2e/perf/runtime-cls-gate.spec.ts index 4984d8702..3a6137bc7 100644 --- a/packages/app/e2e/perf/runtime-cls-gate.spec.ts +++ b/packages/app/e2e/perf/runtime-cls-gate.spec.ts @@ -10,6 +10,7 @@ import { sessionTurnListSelector, } from "../selectors" import { sessionPath } from "../utils" +import { timelineEvent, type TimelineWindow } from "../../src/testing/timeline" import { readTimelineDomBudget } from "./timeline-dom-budget" import { collectRuntimeClsFailures, @@ -129,6 +130,20 @@ async function moveMouseOverTimeline(page: Page) { await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height * 0.25)) } +async function markTimelineWheelIntent(page: Page, deltaY: number) { + const found = await page.evaluate( + ({ deltaY, scrollViewportSelector, turnListSelector }) => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return false + viewport.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY })) + return true + }, + { deltaY, scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) + expect(found).toBe(true) +} + async function positionTimelineForMeasuredWindow(page: Page) { // A tiny real wheel gesture marks the timeline as user-scrolled. Without // that, the active question turn can briefly re-lock to bottom and the @@ -153,6 +168,15 @@ async function revealRuntimeClsRows(page: Page) { await expect(page.locator(sessionMessageItemSelector).first()).toBeVisible({ timeout: 30_000 }) }) + await page.evaluate((eventName) => { + window.dispatchEvent( + new CustomEvent(eventName, { + detail: { action: "reveal-cached" }, + }), + ) + }, timelineEvent) + await settleFrames(page, 4) + for (let attempt = 0; attempt < 24; attempt += 1) { const budget = await readTimelineDomBudget(page) if (budget.totalRows >= RUNTIME_CLS_MINIMUM_ROWS) return budget @@ -175,6 +199,37 @@ async function revealRuntimeClsRows(page: Page) { return await readTimelineDomBudget(page) } +async function revealRuntimeClsRowsNaturally(page: Page) { + await test.step("wait for first runtime CLS message", async () => { + await expect(page.locator(sessionMessageItemSelector).first()).toBeVisible({ timeout: 30_000 }) + }) + + await moveMouseOverTimeline(page) + for (let attempt = 0; attempt < 24; attempt += 1) { + const budget = await readTimelineDomBudget(page) + if (budget.totalRows >= RUNTIME_CLS_MINIMUM_ROWS) return budget + + await test.step(`naturally reveal runtime CLS rows attempt ${attempt + 1}`, async () => { + await page.mouse.wheel(0, -1200) + await markTimelineWheelIntent(page, -1200) + await settleFrames(page, 2) + await scrollTimelineToRatio(page, 0) + await settleFrames(page, 4) + + const loadEarlier = page.getByRole("button", { name: /Load earlier messages|加载更早的消息/i }).first() + if (await loadEarlier.isVisible().catch(() => false)) { + await loadEarlier.click({ timeout: 1_000 }).catch(() => undefined) + await settleFrames(page, 4) + } + }) + } + + await expect + .poll(async () => (await readTimelineDomBudget(page)).totalRows, { timeout: 10_000 }) + .toBeGreaterThanOrEqual(RUNTIME_CLS_MINIMUM_ROWS) + return await readTimelineDomBudget(page) +} + async function centerVisibleMessageID(page: Page) { const target = await page.evaluate( ({ messageSelector, scrollViewportSelector }) => { @@ -202,7 +257,17 @@ async function centerVisibleMessageID(page: Page) { return target! } +async function installTimelineRuntimeClsDriver(page: Page) { + const apply = () => { + const win = window as TimelineWindow + win.__opencode_e2e = { ...win.__opencode_e2e, timeline: { enabled: true } } + } + await page.addInitScript(apply) + await page.evaluate(apply) +} + async function prepareRuntimeClsWindow(page: Page, project: RuntimeClsProject, sessionID: string) { + await installTimelineRuntimeClsDriver(page) await test.step("navigate to runtime CLS session", async () => { await page.goto(sessionPath(project.directory, sessionID)) }) @@ -385,6 +450,20 @@ test.describe("runtime CLS probe lifecycle", () => { test.describe("runtime CLS source gate", () => { test.setTimeout(180_000) + test("natural history reveal smoke reaches a virtualized runtime CLS window", async ({ page, project }) => { + await project.open() + await withSession(project.sdk, `runtime cls natural reveal ${Date.now()}`, async (session) => { + await seedRuntimeClsSession(project, session.id) + await page.goto(sessionPath(project.directory, session.id)) + + const budget = await revealRuntimeClsRowsNaturally(page) + + expect(budget.totalRows).toBeGreaterThanOrEqual(RUNTIME_CLS_MINIMUM_ROWS) + expect(budget.hasVirtualizer).toBe(true) + expect(budget.mountedMessages).toBeLessThanOrEqual(RUNTIME_CLS_MAXIMUM_MOUNTED_MESSAGES) + }) + }) + test("composer growth does not move visible timeline primary sources", async ({ page, project }) => { await installRuntimeClsProbe(page) await project.open() diff --git a/packages/app/e2e/perf/runtime-cls-probe.ts b/packages/app/e2e/perf/runtime-cls-probe.ts index 9d40050c6..12196790f 100644 --- a/packages/app/e2e/perf/runtime-cls-probe.ts +++ b/packages/app/e2e/perf/runtime-cls-probe.ts @@ -18,6 +18,15 @@ export type RuntimeClsScrollMetrics = { maxScrollTop: number } +export type RuntimeClsTransactionSnapshot = { + activeBefore?: boolean + activeAfter?: boolean + idBefore?: string + idAfter?: string + kindBefore?: string + kindAfter?: string +} + export type RuntimeClsSourceKind = | "primary-message-wrapper" | "primary-turn" @@ -62,6 +71,7 @@ export type RuntimeClsSnapshot = { mountedRows?: number scrollBefore?: RuntimeClsScrollMetrics scrollAfter?: RuntimeClsScrollMetrics + transaction?: RuntimeClsTransactionSnapshot } export type RuntimeClsResult = { @@ -277,6 +287,14 @@ export function formatRuntimeClsFailure(input: { })), })) const maxValue = Math.max(0, ...input.entries.map((entry) => entry.value)) + const transactionSummary = input.snapshot.transaction + ? [ + `transaction=${input.snapshot.transaction.idBefore ?? input.snapshot.transaction.idAfter ?? ""}`, + `transactionKind=${input.snapshot.transaction.kindBefore ?? input.snapshot.transaction.kindAfter ?? ""}`, + `transactionActiveBefore=${input.snapshot.transaction.activeBefore ?? false}`, + `transactionActiveAfter=${input.snapshot.transaction.activeAfter ?? false}`, + ].join(" ") + : "transaction=" const sourceSummary = input.entries .flatMap((entry) => entry.sources.map((source) => @@ -293,6 +311,7 @@ export function formatRuntimeClsFailure(input: { return [ `Runtime CLS primary source gate failed during ${input.action}.`, `Threshold: single entry > ${RUNTIME_CLS_PRIMARY_SHIFT_THRESHOLD}; max primary entry: ${maxValue}.`, + transactionSummary, sourceSummary, JSON.stringify( { @@ -352,6 +371,15 @@ function runtimeClsProbeInitScript(options?: RuntimeClsProbeInstallOptions) { maxScrollTop: number } + type RuntimeClsTransactionSnapshot = { + activeBefore?: boolean + activeAfter?: boolean + idBefore?: string + idAfter?: string + kindBefore?: string + kindAfter?: string + } + type RuntimeClsSnapshot = { targetMessageID?: string targetBeforeRect?: RuntimeClsRect @@ -361,6 +389,7 @@ function runtimeClsProbeInitScript(options?: RuntimeClsProbeInstallOptions) { mountedRows?: number scrollBefore?: RuntimeClsScrollMetrics scrollAfter?: RuntimeClsScrollMetrics + transaction?: RuntimeClsTransactionSnapshot } type RuntimeClsWindow = Window & { @@ -564,6 +593,11 @@ function runtimeClsProbeInitScript(options?: RuntimeClsProbeInstallOptions) { totalRows: list?.dataset.totalRows ? Number(list.dataset.totalRows) : undefined, mountedRows: virtualRows > 0 ? virtualRows : messages, scrollAfter: readScrollMetrics(), + transaction: { + activeAfter: list?.dataset.layoutTransactionActive === "true", + idAfter: list?.dataset.layoutTransactionId || undefined, + kindAfter: list?.dataset.layoutTransactionKind || undefined, + }, } } @@ -630,6 +664,11 @@ function runtimeClsProbeInitScript(options?: RuntimeClsProbeInstallOptions) { totalRows: before.totalRows, mountedRows: before.mountedRows, scrollBefore: before.scrollAfter, + transaction: { + activeBefore: before.transaction?.activeAfter, + idBefore: before.transaction?.idAfter, + kindBefore: before.transaction?.kindAfter, + }, } }, stop() { @@ -646,6 +685,12 @@ function runtimeClsProbeInitScript(options?: RuntimeClsProbeInstallOptions) { totalRows: after.totalRows ?? snapshotBefore.totalRows, mountedRows: after.mountedRows ?? snapshotBefore.mountedRows, scrollAfter: after.scrollAfter, + transaction: { + ...snapshotBefore.transaction, + activeAfter: after.transaction?.activeAfter, + idAfter: after.transaction?.idAfter, + kindAfter: after.transaction?.kindAfter, + }, }, } active = false diff --git a/packages/app/e2e/perf/runtime-cls-probe.unit.ts b/packages/app/e2e/perf/runtime-cls-probe.unit.ts index 9390d87e3..bf29d92fe 100644 --- a/packages/app/e2e/perf/runtime-cls-probe.unit.ts +++ b/packages/app/e2e/perf/runtime-cls-probe.unit.ts @@ -171,6 +171,14 @@ describe("runtime CLS failure diagnostics", () => { mountedRows: 24, scrollBefore: { scrollTop: 1200, scrollHeight: 8000, clientHeight: 720, maxScrollTop: 7280 }, scrollAfter: { scrollTop: 1236, scrollHeight: 8036, clientHeight: 720, maxScrollTop: 7316 }, + transaction: { + activeBefore: true, + activeAfter: false, + idBefore: "timeline-layout-7", + kindBefore: "dock-resize", + idAfter: undefined, + kindAfter: undefined, + }, }, }) @@ -182,5 +190,7 @@ describe("runtime CLS failure diagnostics", () => { expect(message).toContain("scrollTop") expect(message).toContain("virtualized") expect(message).toContain("104") + expect(message).toContain("transaction=timeline-layout-7") + expect(message).toContain("transactionKind=dock-resize") }) }) diff --git a/packages/app/script/compare-perf.ts b/packages/app/script/compare-perf.ts index 50bddefdf..20c42d3c1 100644 --- a/packages/app/script/compare-perf.ts +++ b/packages/app/script/compare-perf.ts @@ -1,6 +1,11 @@ import fs from "node:fs/promises" import path from "node:path" -import { comparePerfBaselines, renderPerfBaselineComment, type PerfScenarioSummary } from "../src/testing/perf-metrics" +import { + comparePerfBaselines, + renderPerfBaselineComment, + type PerfBaselineComparison, + type PerfScenarioSummary, +} from "../src/testing/perf-metrics" function readArg(flag: string) { const index = process.argv.indexOf(flag) @@ -18,18 +23,48 @@ async function readPerfFile(filePath: string) { return payload } +async function readFailureScenarioKeys(filePath: string) { + const payload = JSON.parse(await fs.readFile(filePath, "utf8")) as PerfBaselineComparison + if (!Array.isArray(payload.scenarios)) { + throw new Error(`Expected a perf comparison with scenarios in ${filePath}`) + } + return payload.scenarios + .filter((scenario) => scenario.failures.length > 0) + .map((scenario) => `${scenario.profile}:${scenario.scenario}`) +} + +async function inferFailureScenarioSource(input: { outputPath?: string; failuresFromPath?: string }) { + if (input.failuresFromPath) return input.failuresFromPath + if (!input.outputPath || path.basename(input.outputPath) !== "perf-compare-confirm.json") return undefined + const candidate = path.join(path.dirname(input.outputPath), "perf-compare.json") + try { + await fs.access(candidate) + return candidate + } catch { + return undefined + } +} + async function main() { const basePath = readArg("--base") const headPath = readArg("--head") const outputPath = readArg("--output") const commentOutputPath = readArg("--comment-output") + const failuresFromPath = readArg("--failures-from") if (!basePath || !headPath) { - throw new Error("Usage: bun script/compare-perf.ts --base --head [--output ]") + throw new Error( + "Usage: bun script/compare-perf.ts --base --head [--output ]", + ) } - const [base, head] = await Promise.all([readPerfFile(basePath), readPerfFile(headPath)]) - const comparison = comparePerfBaselines({ base, head }) + const failuresSourcePath = await inferFailureScenarioSource({ outputPath, failuresFromPath }) + const [base, head, scenarioKeys] = await Promise.all([ + readPerfFile(basePath), + readPerfFile(headPath), + failuresSourcePath ? readFailureScenarioKeys(failuresSourcePath) : undefined, + ]) + const comparison = comparePerfBaselines({ base, head, scenarioKeys }) if (outputPath) { await fs.mkdir(path.dirname(outputPath), { recursive: true }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 3bb7c86d9..feed09f33 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -488,6 +488,9 @@ export default function Page() { historyLoading={timelineHistoryLoading()} anchor={timelineInteraction.anchor} virtualizerBridge={timelineInteraction.virtualizerBridge} + layoutTransactionActive={timelineInteraction.layoutTransactionActive} + layoutTransactionID={timelineInteraction.layoutTransactionID} + layoutTransactionKind={timelineInteraction.layoutTransactionKind} onRetryOpenSession={retryOpenRouteSession} onOpenNewSession={openNewRouteSession} composerSession={renderComposerRegion()} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index ddeab6b06..aa9561524 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -94,6 +94,9 @@ export function MessageTimeline(props: { renderedUserMessages: UserMessage[] anchor: (id: string) => string virtualizerBridge: TimelineVirtualizerBridge + layoutTransactionActive: () => boolean + layoutTransactionID: () => string | undefined + layoutTransactionKind: () => string | undefined }) { let touchGesture: number | undefined let scrollSampleFrame: number | undefined @@ -486,6 +489,9 @@ export function MessageTimeline(props: { data-slot="session-turn-list" data-render-mode={rowRenderMode()} data-total-rows={rowMutation().rows.length} + data-layout-transaction-active={props.layoutTransactionActive() ? "true" : "false"} + data-layout-transaction-id={props.layoutTransactionID()} + data-layout-transaction-kind={props.layoutTransactionKind()} class="transition-[margin]" classList={{ "w-full": true, @@ -500,6 +506,7 @@ export function MessageTimeline(props: { viewport={virtualizerViewport()} virtualizerBridge={props.virtualizerBridge} shift={rowMutation().mutation === "prepend"} + transactionActive={props.layoutTransactionActive()} renderRow={renderTimelineRow} /> diff --git a/packages/app/src/pages/session/session-main-view.tsx b/packages/app/src/pages/session/session-main-view.tsx index f6c8f474e..090fda9bb 100644 --- a/packages/app/src/pages/session/session-main-view.tsx +++ b/packages/app/src/pages/session/session-main-view.tsx @@ -10,6 +10,7 @@ import { shouldShowSessionOpeningState } from "@/pages/session/session-main-view import type { createSessionHistoryWindow } from "@/pages/session/use-session-history-window" import type { createSessionReviewState } from "@/pages/session/use-session-review-state" import type { createSessionScrollDock } from "@/pages/session/use-session-scroll-dock" +import { TimelineE2EDriverBoundary } from "@/testing/timeline" type TimelineProps = ComponentProps @@ -46,6 +47,9 @@ export function SessionMainView(props: { historyLoading: boolean anchor: TimelineProps["anchor"] virtualizerBridge: TimelineProps["virtualizerBridge"] + layoutTransactionActive: TimelineProps["layoutTransactionActive"] + layoutTransactionID: TimelineProps["layoutTransactionID"] + layoutTransactionKind: TimelineProps["layoutTransactionKind"] onRetryOpenSession: () => void onOpenNewSession: () => void composerSession: JSX.Element @@ -68,6 +72,10 @@ export function SessionMainView(props: { return (
+ props.timelineSessionID} + revealCached={() => props.historyWindow.expandForHash(0)} + />
@@ -159,6 +167,9 @@ export function SessionMainView(props: { renderedUserMessages={props.historyWindow.renderedUserMessages()} anchor={props.anchor} virtualizerBridge={props.virtualizerBridge} + layoutTransactionActive={props.layoutTransactionActive} + layoutTransactionID={props.layoutTransactionID} + layoutTransactionKind={props.layoutTransactionKind} /> diff --git a/packages/app/src/pages/session/session-timeline-scroll-controller.ts b/packages/app/src/pages/session/session-timeline-scroll-controller.ts index 409ffa061..5281e8211 100644 --- a/packages/app/src/pages/session/session-timeline-scroll-controller.ts +++ b/packages/app/src/pages/session/session-timeline-scroll-controller.ts @@ -118,6 +118,7 @@ export type TimelineScrollObservation = previousDockHeight: number nextDockHeight: number metrics: TimelineScrollMetrics + layoutTransactionHandled?: boolean } | { type: "owner_detached" @@ -283,7 +284,8 @@ export function createTimelineScrollControllerDiagnostic(input: { } function isExplicitTopIntent(intent: TimelineScrollIntent) { - if (intent.type === "keyboard_scroll") return intent.key === "ArrowUp" || intent.key === "Home" || intent.key === "PageUp" + if (intent.type === "keyboard_scroll") + return intent.key === "ArrowUp" || intent.key === "Home" || intent.key === "PageUp" if (intent.type === "wheel_scroll" || intent.type === "touch_scroll") { return intent.direction === "up" && !intent.nestedScrollable } @@ -302,7 +304,10 @@ function updateSafePosition(state: TimelineScrollControllerState, safePosition: if (safePosition) state.lastSafePosition = safePosition } -function updateObservedSafePosition(state: TimelineScrollControllerState, safePosition: TimelineSafePosition | undefined) { +function updateObservedSafePosition( + state: TimelineScrollControllerState, + safePosition: TimelineSafePosition | undefined, +) { if ( state.mode === "targeting_message" && state.lastSafePosition.kind === "target_message" && diff --git a/packages/app/src/pages/session/timeline-layout-recovery-policy.ts b/packages/app/src/pages/session/timeline-layout-recovery-policy.ts new file mode 100644 index 000000000..bf40557f6 --- /dev/null +++ b/packages/app/src/pages/session/timeline-layout-recovery-policy.ts @@ -0,0 +1,12 @@ +import type { TimelineScrollObservation } from "./session-timeline-scroll-controller" + +export function shouldApplyTimelineRecoveryForObservation(input: { + layoutTransactionActive: boolean + layoutTransactionHandled?: boolean + observationType: TimelineScrollObservation["type"] +}) { + const resizeObservation = input.observationType === "content_resize" || input.observationType === "dock_resize" + if (input.layoutTransactionHandled && resizeObservation) return false + if (!input.layoutTransactionActive) return true + return !resizeObservation +} diff --git a/packages/app/src/pages/session/timeline-layout-stable-band.test.ts b/packages/app/src/pages/session/timeline-layout-stable-band.test.ts new file mode 100644 index 000000000..583ba15df --- /dev/null +++ b/packages/app/src/pages/session/timeline-layout-stable-band.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { + chooseTimelineVirtualizerOverscan, + TIMELINE_BASE_OVERSCAN, + TIMELINE_TRANSACTION_OVERSCAN, +} from "./timeline-layout-stable-band" + +describe("timeline layout stable band", () => { + test("keeps normal overscan small outside transactions", () => { + expect(chooseTimelineVirtualizerOverscan({ transactionActive: false })).toBe(TIMELINE_BASE_OVERSCAN) + }) + + test("widens overscan only during a layout transaction", () => { + expect(chooseTimelineVirtualizerOverscan({ transactionActive: true })).toBe(TIMELINE_TRANSACTION_OVERSCAN) + expect(TIMELINE_TRANSACTION_OVERSCAN).toBeGreaterThan(TIMELINE_BASE_OVERSCAN) + }) +}) diff --git a/packages/app/src/pages/session/timeline-layout-stable-band.ts b/packages/app/src/pages/session/timeline-layout-stable-band.ts new file mode 100644 index 000000000..d4faeaa89 --- /dev/null +++ b/packages/app/src/pages/session/timeline-layout-stable-band.ts @@ -0,0 +1,6 @@ +export const TIMELINE_BASE_OVERSCAN = 8 +export const TIMELINE_TRANSACTION_OVERSCAN = 24 + +export function chooseTimelineVirtualizerOverscan(input: { transactionActive: boolean }) { + return input.transactionActive ? TIMELINE_TRANSACTION_OVERSCAN : TIMELINE_BASE_OVERSCAN +} diff --git a/packages/app/src/pages/session/timeline-layout-transaction.test.ts b/packages/app/src/pages/session/timeline-layout-transaction.test.ts new file mode 100644 index 000000000..5a20d00ee --- /dev/null +++ b/packages/app/src/pages/session/timeline-layout-transaction.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test } from "bun:test" +import { + createTimelineLayoutTransactionCoordinator, + type TimelineLayoutTransactionDiagnostic, + type TimelineLayoutTransactionFrameScheduler, +} from "./timeline-layout-transaction" +import type { TimelineSafePosition, TimelineScrollMode } from "./session-timeline-scroll-controller" + +const readingAnchor: TimelineSafePosition = { + kind: "reading", + anchorMessageID: "msg-2", + offsetFromViewportTop: 96, + renderedStart: 0, + renderedCount: 12, +} + +function immediateFrameScheduler(): TimelineLayoutTransactionFrameScheduler { + return (callback) => { + callback() + return 1 + } +} + +function deferredFrameScheduler() { + const callbacks: Array<() => void> = [] + const scheduler: TimelineLayoutTransactionFrameScheduler = (callback) => { + callbacks.push(callback) + return callbacks.length + } + return { + scheduler, + pendingFrames: () => callbacks.length, + flushNextFrame: () => callbacks.shift()?.(), + } +} + +function makeCoordinator(input?: { + mode?: TimelineScrollMode + restoreResults?: boolean[] + diagnostics?: TimelineLayoutTransactionDiagnostic[] +}) { + let restoreCalls = 0 + let latestCalls = 0 + let stableBand = false + const restoreResults = input?.restoreResults ?? [true] + const diagnostics = input?.diagnostics ?? [] + const coordinator = createTimelineLayoutTransactionCoordinator({ + now: () => 123 + diagnostics.length, + scheduleFrame: immediateFrameScheduler(), + cancelFrame: () => {}, + readMode: () => input?.mode ?? "reading_history", + sampleAnchor: () => readingAnchor, + restoreAnchor: () => restoreResults[Math.min(restoreCalls++, restoreResults.length - 1)] ?? false, + restoreLatest: () => { + latestCalls += 1 + return true + }, + setStableBandActive: (active) => { + stableBand = active + }, + emitDiagnostic: (event) => diagnostics.push(event), + }) + return { + coordinator, + diagnostics, + restoreCalls: () => restoreCalls, + latestCalls: () => latestCalls, + stableBand: () => stableBand, + } +} + +describe("timeline layout transaction coordinator", () => { + test("restores the sampled reading anchor before paint", () => { + const { coordinator, diagnostics, restoreCalls, stableBand } = makeCoordinator() + const mutations: string[] = [] + + const result = coordinator.run({ + kind: "dock-resize", + source: "use-session-scroll-dock/updateDockHeight", + reason: "question-dock-close", + mutate: () => mutations.push("dock-height-applied"), + }) + + expect(mutations).toEqual(["dock-height-applied"]) + expect(restoreCalls()).toBe(1) + expect(stableBand()).toBe(false) + expect(result.status).toBe("before-paint") + expect(result.anchor).toEqual(readingAnchor) + expect(diagnostics.map((event) => event.phase)).toEqual(["start", "settled"]) + }) + + test("preserves latest instead of reading anchor when already following latest", () => { + const { coordinator, latestCalls, restoreCalls } = makeCoordinator({ mode: "following_latest" }) + + const result = coordinator.run({ + kind: "dock-resize", + source: "use-session-scroll-dock/updateDockHeight", + reason: "composer-growth", + mutate: () => {}, + }) + + expect(result.status).toBe("before-paint") + expect(result.anchor).toEqual({ kind: "latest" }) + expect(latestCalls()).toBe(1) + expect(restoreCalls()).toBe(0) + }) + + test("uses a bounded two-frame fallback when the anchor is not immediately restorable", () => { + const { coordinator, diagnostics, restoreCalls } = makeCoordinator({ restoreResults: [false, true] }) + + const result = coordinator.run({ + kind: "content-resize", + source: "use-session-scroll-dock/contentObserver", + reason: "streaming-content-resize", + mutate: () => {}, + }) + + expect(result.status).toBe("fallback") + expect(result.fallbackFrames).toBe(1) + expect(restoreCalls()).toBe(2) + expect(diagnostics.map((event) => event.phase)).toEqual(["start", "fallback", "settled"]) + }) + + test("reports a violation after the two-frame fallback budget is exceeded", () => { + const { coordinator, diagnostics, restoreCalls } = makeCoordinator({ restoreResults: [false, false, false] }) + + const result = coordinator.run({ + kind: "row-measurement", + source: "timeline-virtualizer-bridge/measurement", + reason: "virtual-row-height-change", + mutate: () => {}, + }) + + expect(result.status).toBe("violation") + expect(result.violation).toBe("anchor_restore_exceeded_fallback_budget") + expect(result.fallbackFrames).toBe(2) + expect(restoreCalls()).toBe(3) + expect(diagnostics.at(-1)).toMatchObject({ + phase: "violation", + violation: "anchor_restore_exceeded_fallback_budget", + }) + }) + + test("keeps async fallback transactions active until a scheduled frame settles", () => { + const frame = deferredFrameScheduler() + let restoreCalls = 0 + let stableBand = false + const diagnostics: TimelineLayoutTransactionDiagnostic[] = [] + const coordinator = createTimelineLayoutTransactionCoordinator({ + now: () => 200 + diagnostics.length, + scheduleFrame: frame.scheduler, + cancelFrame: () => {}, + readMode: () => "reading_history", + sampleAnchor: () => readingAnchor, + restoreAnchor: () => { + restoreCalls += 1 + return restoreCalls === 2 + }, + restoreLatest: () => false, + setStableBandActive: (active) => { + stableBand = active + }, + emitDiagnostic: (event) => diagnostics.push(event), + }) + + const result = coordinator.run({ + kind: "dock-resize", + source: "use-session-scroll-dock/updateDockHeight", + reason: "question-dock-open", + mutate: () => {}, + }) + + expect(result.status).toBe("fallback") + expect(result.fallbackFrames).toBe(1) + expect(restoreCalls).toBe(1) + expect(stableBand).toBe(true) + expect(frame.pendingFrames()).toBe(1) + expect(diagnostics.map((event) => event.phase)).toEqual(["start", "fallback"]) + + frame.flushNextFrame() + + expect(restoreCalls).toBe(2) + expect(stableBand).toBe(false) + expect(diagnostics.map((event) => event.phase)).toEqual(["start", "fallback", "settled"]) + }) + + test("keeps async fallback transactions active until second-frame violation", () => { + const frame = deferredFrameScheduler() + let restoreCalls = 0 + let stableBand = false + const diagnostics: TimelineLayoutTransactionDiagnostic[] = [] + const coordinator = createTimelineLayoutTransactionCoordinator({ + now: () => 300 + diagnostics.length, + scheduleFrame: frame.scheduler, + cancelFrame: () => {}, + readMode: () => "reading_history", + sampleAnchor: () => readingAnchor, + restoreAnchor: () => { + restoreCalls += 1 + return false + }, + restoreLatest: () => false, + setStableBandActive: (active) => { + stableBand = active + }, + emitDiagnostic: (event) => diagnostics.push(event), + }) + + coordinator.run({ + kind: "content-resize", + source: "use-session-scroll-dock/contentObserver", + reason: "streaming-content-resize", + mutate: () => {}, + }) + + expect(stableBand).toBe(true) + expect(diagnostics.map((event) => event.phase)).toEqual(["start", "fallback"]) + + frame.flushNextFrame() + + expect(stableBand).toBe(true) + expect(diagnostics.map((event) => event.phase)).toEqual(["start", "fallback", "fallback"]) + + frame.flushNextFrame() + + expect(restoreCalls).toBe(3) + expect(stableBand).toBe(false) + expect(diagnostics.map((event) => event.phase)).toEqual(["start", "fallback", "fallback", "violation"]) + expect(diagnostics.at(-1)?.violation).toBe("anchor_restore_exceeded_fallback_budget") + }) + + test("cancels stale fallback frames when a newer transaction starts", () => { + const frame = deferredFrameScheduler() + const canceledHandles: number[] = [] + const diagnostics: TimelineLayoutTransactionDiagnostic[] = [] + let firstRestoreCalls = 0 + const coordinator = createTimelineLayoutTransactionCoordinator({ + now: () => 400 + diagnostics.length, + scheduleFrame: frame.scheduler, + cancelFrame: (handle) => canceledHandles.push(handle), + readMode: () => "reading_history", + sampleAnchor: () => readingAnchor, + restoreAnchor: () => { + firstRestoreCalls += 1 + return firstRestoreCalls > 2 + }, + restoreLatest: () => false, + setStableBandActive: () => {}, + emitDiagnostic: (event) => diagnostics.push(event), + }) + + const first = coordinator.run({ + kind: "content-resize", + source: "use-session-scroll-dock/contentObserver", + reason: "streaming-content-resize", + mutate: () => {}, + }) + const second = coordinator.run({ + kind: "dock-resize", + source: "use-session-scroll-dock/updateDockHeight", + reason: "question-dock-open", + mutate: () => {}, + }) + + expect(first.status).toBe("fallback") + expect(second.status).toBe("fallback") + expect(canceledHandles).toEqual([1]) + + frame.flushNextFrame() + frame.flushNextFrame() + + expect(diagnostics.map((event) => `${event.transactionID}:${event.phase}`)).toEqual([ + "timeline-layout-1:start", + "timeline-layout-1:fallback", + "timeline-layout-2:start", + "timeline-layout-2:fallback", + "timeline-layout-2:settled", + ]) + }) + + test("reports active transaction state explicitly instead of through diagnostics", () => { + const frame = deferredFrameScheduler() + const states: Array<{ active: boolean; transactionID?: string; kind?: string }> = [] + const diagnostics: TimelineLayoutTransactionDiagnostic[] = [] + const coordinator = createTimelineLayoutTransactionCoordinator({ + now: () => 500 + diagnostics.length, + scheduleFrame: frame.scheduler, + cancelFrame: () => {}, + readMode: () => "reading_history", + sampleAnchor: () => readingAnchor, + restoreAnchor: () => diagnostics.length > 1, + restoreLatest: () => false, + setStableBandActive: () => {}, + setTransactionState: (state) => states.push(state), + emitDiagnostic: (event) => diagnostics.push(event), + }) + + coordinator.run({ + kind: "dock-resize", + source: "use-session-scroll-dock/updateDockHeight", + reason: "composer-growth", + mutate: () => {}, + }) + + expect(states).toEqual([{ active: true, transactionID: "timeline-layout-1", kind: "dock-resize" }]) + + frame.flushNextFrame() + + expect(states).toEqual([ + { active: true, transactionID: "timeline-layout-1", kind: "dock-resize" }, + { active: false }, + ]) + }) + + test("cancels pending fallback before stale frames can restore or violate", () => { + const frame = deferredFrameScheduler() + let restoreCalls = 0 + let stableBand = false + const diagnostics: TimelineLayoutTransactionDiagnostic[] = [] + const coordinator = createTimelineLayoutTransactionCoordinator({ + now: () => 600 + diagnostics.length, + scheduleFrame: frame.scheduler, + cancelFrame: () => {}, + readMode: () => "reading_history", + sampleAnchor: () => readingAnchor, + restoreAnchor: () => { + restoreCalls += 1 + return false + }, + restoreLatest: () => false, + setStableBandActive: (active) => { + stableBand = active + }, + emitDiagnostic: (event) => diagnostics.push(event), + }) + + coordinator.run({ + kind: "content-resize", + source: "use-session-scroll-dock/contentObserver", + reason: "streaming-content-resize", + mutate: () => {}, + }) + + coordinator.cancel() + frame.flushNextFrame() + + expect(restoreCalls).toBe(1) + expect(stableBand).toBe(false) + expect(diagnostics.map((event) => event.phase)).toEqual(["start", "fallback"]) + }) +}) diff --git a/packages/app/src/pages/session/timeline-layout-transaction.ts b/packages/app/src/pages/session/timeline-layout-transaction.ts new file mode 100644 index 000000000..a3ed57b7d --- /dev/null +++ b/packages/app/src/pages/session/timeline-layout-transaction.ts @@ -0,0 +1,198 @@ +import type { TimelineSafePosition, TimelineScrollMode } from "./session-timeline-scroll-controller" + +export type TimelineLayoutTransactionKind = "dock-resize" | "content-resize" | "row-measurement" | "end-of-turn-settle" +export type TimelineLayoutTransactionPhase = "start" | "fallback" | "settled" | "violation" +export type TimelineLayoutTransactionStatus = "before-paint" | "fallback" | "violation" | "no-op" +export type TimelineLayoutTransactionViolation = "anchor_restore_exceeded_fallback_budget" + +export type TimelineLayoutTransactionFrameScheduler = (callback: () => void) => number + +export type TimelineLayoutTransactionDiagnostic = { + transactionID: string + kind: TimelineLayoutTransactionKind + phase: TimelineLayoutTransactionPhase + monotonicMs: number + mode: TimelineScrollMode + source: string + reason: string + anchorKind: TimelineSafePosition["kind"] + anchorMessageID?: string + fallbackFrames: number + violation?: TimelineLayoutTransactionViolation +} + +export type TimelineLayoutTransactionResult = { + transactionID: string + kind: TimelineLayoutTransactionKind + status: TimelineLayoutTransactionStatus + anchor: TimelineSafePosition + fallbackFrames: number + violation?: TimelineLayoutTransactionViolation +} + +export type TimelineLayoutTransactionRunInput = { + kind: TimelineLayoutTransactionKind + source: string + reason: string + mutate: () => void + mode?: TimelineScrollMode + restoreLatest?: (transactionID: string) => boolean +} + +export type TimelineLayoutTransactionState = + | { + active: true + transactionID: string + kind: TimelineLayoutTransactionKind + } + | { + active: false + transactionID?: undefined + kind?: undefined + } + +function anchorMessageID(anchor: TimelineSafePosition) { + if (anchor.kind === "reading") return anchor.anchorMessageID + if (anchor.kind === "target_message") return anchor.messageID + return anchor.messageID +} + +function latestAnchor(): TimelineSafePosition { + return { kind: "latest" } +} + +export function createTimelineLayoutTransactionCoordinator(input: { + now?: () => number + scheduleFrame: TimelineLayoutTransactionFrameScheduler + cancelFrame: (handle: number) => void + readMode: () => TimelineScrollMode + sampleAnchor: () => TimelineSafePosition + restoreAnchor: (anchor: TimelineSafePosition, transactionID: string) => boolean + restoreLatest: (transactionID: string) => boolean + setStableBandActive: (active: boolean) => void + setTransactionState?: (state: TimelineLayoutTransactionState) => void + emitDiagnostic?: (event: TimelineLayoutTransactionDiagnostic) => void +}) { + let sequence = 0 + let activeGeneration = 0 + let transactionPending = false + let pendingFrameHandles = new Set() + const now = input.now ?? (() => performance.now()) + + const emit = (event: Omit) => { + input.emitDiagnostic?.({ ...event, monotonicMs: now() }) + } + + const clearPendingFrames = () => { + for (const handle of pendingFrameHandles) input.cancelFrame(handle) + pendingFrameHandles = new Set() + } + + const cancel = () => { + if (!transactionPending && pendingFrameHandles.size <= 0) return + activeGeneration += 1 + transactionPending = false + clearPendingFrames() + input.setStableBandActive(false) + input.setTransactionState?.({ active: false }) + } + + const run = (runInput: TimelineLayoutTransactionRunInput): TimelineLayoutTransactionResult => { + cancel() + sequence += 1 + activeGeneration += 1 + const generation = activeGeneration + const transactionID = `timeline-layout-${sequence}` + const mode = runInput.mode ?? input.readMode() + const anchor = mode === "following_latest" ? latestAnchor() : input.sampleAnchor() + const restoreLatestForTransaction = runInput.restoreLatest ?? input.restoreLatest + const base = { + transactionID, + kind: runInput.kind, + mode, + source: runInput.source, + reason: runInput.reason, + anchorKind: anchor.kind, + anchorMessageID: anchorMessageID(anchor), + } + let settled = false + let result: TimelineLayoutTransactionResult = { + transactionID, + kind: runInput.kind, + status: "fallback", + anchor, + fallbackFrames: 1, + } + + const restore = () => + anchor.kind === "latest" ? restoreLatestForTransaction(transactionID) : input.restoreAnchor(anchor, transactionID) + + const settle = ( + status: TimelineLayoutTransactionStatus, + fallbackFrames: number, + violation?: TimelineLayoutTransactionViolation, + ) => { + if (generation !== activeGeneration || settled) return false + settled = true + transactionPending = false + input.setStableBandActive(false) + input.setTransactionState?.({ active: false }) + if (violation) { + emit({ ...base, phase: "violation", fallbackFrames, violation }) + } else { + emit({ ...base, phase: "settled", fallbackFrames }) + } + result = { transactionID, kind: runInput.kind, status, anchor, fallbackFrames, violation } + return true + } + + const attemptFallbackRestore = (frame: number) => { + if (generation !== activeGeneration || settled) return + if (restore()) { + settle("fallback", frame) + return + } + if (frame >= 2) { + settle("violation", frame, "anchor_restore_exceeded_fallback_budget") + return + } + scheduleFallback(frame + 1) + } + + const scheduleFallback = (frame: number) => { + if (generation !== activeGeneration || settled) return + emit({ ...base, phase: "fallback", fallbackFrames: frame }) + let handle: number | undefined + handle = input.scheduleFrame(() => { + if (handle !== undefined) pendingFrameHandles.delete(handle) + attemptFallbackRestore(frame) + }) + if (!settled && generation === activeGeneration) pendingFrameHandles.add(handle) + } + + input.setStableBandActive(true) + transactionPending = true + input.setTransactionState?.({ active: true, transactionID, kind: runInput.kind }) + emit({ ...base, phase: "start", fallbackFrames: 0 }) + + try { + runInput.mutate() + + if (restore()) { + settle("before-paint", 0) + return { transactionID, kind: runInput.kind, status: "before-paint", anchor, fallbackFrames: 0 } + } + + scheduleFallback(1) + return result + } catch (error) { + if (generation === activeGeneration) clearPendingFrames() + transactionPending = false + input.setStableBandActive(false) + input.setTransactionState?.({ active: false }) + throw error + } + } + + return { run, cancel } +} diff --git a/packages/app/src/pages/session/timeline-row-renderer.tsx b/packages/app/src/pages/session/timeline-row-renderer.tsx index 584944f34..1d269d1af 100644 --- a/packages/app/src/pages/session/timeline-row-renderer.tsx +++ b/packages/app/src/pages/session/timeline-row-renderer.tsx @@ -3,6 +3,7 @@ import { Virtualizer } from "virtua/solid" import type { TimelineVirtualizerBridge } from "./timeline-virtualizer-bridge" import type { TimelineVirtualRow } from "./timeline-virtual-rows" import type { TimelineRowRenderMode } from "./timeline-virtualization-strategy" +import { chooseTimelineVirtualizerOverscan } from "./timeline-layout-stable-band" export function TimelineRowRenderer(props: { mode: TimelineRowRenderMode @@ -10,6 +11,7 @@ export function TimelineRowRenderer(props: { viewport: HTMLDivElement | undefined virtualizerBridge: TimelineVirtualizerBridge shift: boolean + transactionActive: boolean renderRow: (row: TimelineVirtualRow) => JSX.Element }) { createEffect(() => { @@ -33,6 +35,7 @@ function VirtualizedTimelineRows(props: { viewport: HTMLDivElement | undefined virtualizerBridge: TimelineVirtualizerBridge shift: boolean + transactionActive: boolean renderRow: (row: TimelineVirtualRow) => JSX.Element }) { return ( @@ -43,7 +46,7 @@ function VirtualizedTimelineRows(props: { data={props.rows} scrollRef={viewport()} shift={props.shift} - overscan={8} + overscan={chooseTimelineVirtualizerOverscan({ transactionActive: props.transactionActive })} > {props.renderRow} diff --git a/packages/app/src/pages/session/timeline-scroll-command-sink.test.ts b/packages/app/src/pages/session/timeline-scroll-command-sink.test.ts index 28d1611cf..32458af42 100644 --- a/packages/app/src/pages/session/timeline-scroll-command-sink.test.ts +++ b/packages/app/src/pages/session/timeline-scroll-command-sink.test.ts @@ -100,6 +100,46 @@ describe("TimelineScrollCommandSink", () => { expect(sink.records().map((record) => record.source)).toEqual(["two", "three"]) }) + test("records transaction metadata on scoped commands", () => { + const scroller = makeScroller({ clientHeight: 100, scrollHeight: 900, scrollTop: 12 }) + const sink = createTimelineScrollCommandSink({ now: () => 456 }) + const scoped = sink.withTransaction({ transactionID: "tx-1", transactionKind: "dock-resize" }) + + scoped.setScrollTop({ element: scroller.el, top: 120, type: "anchor-restore", source: "transaction-test" }) + + expect(sink.records()[0]).toMatchObject({ + transactionID: "tx-1", + transactionKind: "dock-resize", + type: "anchor-restore", + source: "transaction-test", + }) + }) + + test("emits a transaction violation when an unscoped command runs during an active transaction", () => { + const events: unknown[] = [] + const scroller = makeScroller({ clientHeight: 100, scrollHeight: 900, scrollTop: 12 }) + const sink = createTimelineScrollCommandSink({ + activeTransaction: () => ({ transactionID: "tx-2", transactionKind: "content-resize" }), + emitDiagnostic: (event) => { + events.push(event) + }, + }) + + sink.setScrollTop({ element: scroller.el, top: 220, type: "bottom-follow", source: "legacy-bottom-follow" }) + + expect(events).toContainEqual( + expect.objectContaining({ + name: "session.timeline.layout_transaction_violation", + data: expect.objectContaining({ + transaction_id: "tx-2", + transaction_kind: "content-resize", + violation: "scroll_command_outside_transaction", + command_source: "legacy-bottom-follow", + }), + }), + ) + }) + test("keeps diagnostic failures out of the scroll path", async () => { const scroller = makeScroller({ clientHeight: 100, scrollHeight: 900, scrollTop: 0 }) const syncSink = createTimelineScrollCommandSink({ diff --git a/packages/app/src/pages/session/timeline-scroll-command-sink.ts b/packages/app/src/pages/session/timeline-scroll-command-sink.ts index 18b681f07..ae6b270ca 100644 --- a/packages/app/src/pages/session/timeline-scroll-command-sink.ts +++ b/packages/app/src/pages/session/timeline-scroll-command-sink.ts @@ -1,4 +1,5 @@ import type { RendererDiagnosticInput } from "@/context/platform" +import type { TimelineLayoutTransactionKind } from "./timeline-layout-transaction" export type TimelineScrollCommandType = | "anchor-restore" @@ -24,24 +25,33 @@ export type TimelineScrollCommandContext = { timelineSessionID?: string } -export type TimelineScrollCommandRecord = TimelineScrollCommandContext & { - monotonicMs: number - type: TimelineScrollCommandType - source: string - method: TimelineScrollCommandMethod - top: number - behavior?: ScrollBehavior - reason?: string - before?: TimelineScrollCommandMetrics - after?: TimelineScrollCommandMetrics +export type TimelineScrollCommandTransaction = { + transactionID: string + transactionKind: TimelineLayoutTransactionKind } +type TimelineScrollCommandTransactionPartial = Partial + +export type TimelineScrollCommandRecord = TimelineScrollCommandContext & + TimelineScrollCommandTransactionPartial & { + monotonicMs: number + type: TimelineScrollCommandType + source: string + method: TimelineScrollCommandMethod + top: number + behavior?: ScrollBehavior + reason?: string + before?: TimelineScrollCommandMetrics + after?: TimelineScrollCommandMetrics + } + type TimelineScrollCommandBase = { element: HTMLElement top: number type: TimelineScrollCommandType source: string reason?: string + transaction?: TimelineScrollCommandTransaction } export type TimelineSetScrollTopCommand = TimelineScrollCommandBase @@ -51,6 +61,7 @@ export type TimelineScrollCommandSink = { setScrollTop: (command: TimelineSetScrollTopCommand) => TimelineScrollCommandRecord scrollTo: (command: TimelineScrollToCommand) => TimelineScrollCommandRecord records: () => TimelineScrollCommandRecord[] + withTransaction: (transaction: TimelineScrollCommandTransaction) => TimelineScrollCommandSink } export function collectTimelineScrollCommandMetrics(element: HTMLElement): TimelineScrollCommandMetrics { @@ -64,43 +75,76 @@ export function collectTimelineScrollCommandMetrics(element: HTMLElement): Timel } export function createTimelineScrollCommandSink(input?: { + activeTransaction?: () => TimelineScrollCommandTransaction | undefined emitDiagnostic?: (event: RendererDiagnosticInput) => Promise | void fullMetricsEnabled?: () => boolean getContext?: () => TimelineScrollCommandContext maxRecords?: number now?: () => number + transaction?: TimelineScrollCommandTransaction }): TimelineScrollCommandSink { const maxRecords = Math.max(1, input?.maxRecords ?? 100) const records: TimelineScrollCommandRecord[] = [] const now = input?.now ?? (() => performance.now()) - const remember = (record: TimelineScrollCommandRecord) => { - records.push(record) - while (records.length > maxRecords) records.shift() + const emitDiagnostic = (event: RendererDiagnosticInput) => { try { - const maybePromise = input?.emitDiagnostic?.({ - name: "session.timeline.scroll_command", - route_session_id: record.routeSessionID, - visible_session_id: record.visibleSessionID, - timeline_session_id: record.timelineSessionID, - monotonic_ms: record.monotonicMs, - data: { - command_type: record.type, - command_method: record.method, - command_source: record.source, - command_reason: record.reason, - command_top: record.top, - command_behavior: record.behavior, - before_scroll_top: record.before?.scrollTop, - before_distance_from_bottom: record.before?.distanceFromBottom, - after_scroll_top: record.after?.scrollTop, - after_distance_from_bottom: record.after?.distanceFromBottom, - }, - }) + const maybePromise = input?.emitDiagnostic?.(event) void maybePromise?.catch?.(() => {}) } catch { // Diagnostics should never affect timeline scroll command execution. } + } + + const maybeEmitTransactionViolation = (record: TimelineScrollCommandRecord) => { + const activeTransaction = input?.activeTransaction?.() + if (!activeTransaction) return + if (record.transactionID === activeTransaction.transactionID) return + emitDiagnostic({ + name: "session.timeline.layout_transaction_violation", + route_session_id: record.routeSessionID, + visible_session_id: record.visibleSessionID, + timeline_session_id: record.timelineSessionID, + monotonic_ms: record.monotonicMs, + data: { + transaction_id: activeTransaction.transactionID, + transaction_kind: activeTransaction.transactionKind, + command_transaction_id: record.transactionID, + command_transaction_kind: record.transactionKind, + violation: "scroll_command_outside_transaction", + command_type: record.type, + command_method: record.method, + command_source: record.source, + command_reason: record.reason, + }, + }) + } + + const remember = (record: TimelineScrollCommandRecord) => { + records.push(record) + while (records.length > maxRecords) records.shift() + maybeEmitTransactionViolation(record) + emitDiagnostic({ + name: "session.timeline.scroll_command", + route_session_id: record.routeSessionID, + visible_session_id: record.visibleSessionID, + timeline_session_id: record.timelineSessionID, + monotonic_ms: record.monotonicMs, + data: { + command_type: record.type, + command_method: record.method, + command_source: record.source, + command_reason: record.reason, + command_top: record.top, + command_behavior: record.behavior, + transaction_id: record.transactionID, + transaction_kind: record.transactionKind, + before_scroll_top: record.before?.scrollTop, + before_distance_from_bottom: record.before?.distanceFromBottom, + after_scroll_top: record.after?.scrollTop, + after_distance_from_bottom: record.after?.distanceFromBottom, + }, + }) return record } @@ -108,13 +152,16 @@ export function createTimelineScrollCommandSink(input?: { command: TimelineSetScrollTopCommand | TimelineScrollToCommand, method: TimelineScrollCommandMethod, apply: () => void, + sinkTransaction?: TimelineScrollCommandTransaction, ) => { const fullMetrics = input?.fullMetricsEnabled?.() ?? false const before = fullMetrics ? collectTimelineScrollCommandMetrics(command.element) : undefined apply() const after = fullMetrics ? collectTimelineScrollCommandMetrics(command.element) : undefined + const transaction = command.transaction ?? sinkTransaction ?? input?.transaction return remember({ ...(input?.getContext?.() ?? {}), + ...transaction, monotonicMs: now(), type: command.type, source: command.source, @@ -127,15 +174,28 @@ export function createTimelineScrollCommandSink(input?: { }) } - return { + const makeSink = (sinkTransaction?: TimelineScrollCommandTransaction): TimelineScrollCommandSink => ({ setScrollTop: (command) => - execute(command, "set-scroll-top", () => { - command.element.scrollTop = command.top - }), + execute( + command, + "set-scroll-top", + () => { + command.element.scrollTop = command.top + }, + sinkTransaction, + ), scrollTo: (command) => - execute(command, "scroll-to", () => { - command.element.scrollTo({ top: command.top, behavior: command.behavior }) - }), + execute( + command, + "scroll-to", + () => { + command.element.scrollTo({ top: command.top, behavior: command.behavior }) + }, + sinkTransaction, + ), records: () => [...records], - } + withTransaction: (transaction) => makeSink(transaction), + }) + + return makeSink(input?.transaction) } diff --git a/packages/app/src/pages/session/use-session-scroll-dock.test.ts b/packages/app/src/pages/session/use-session-scroll-dock.test.ts index 9e270a66a..4e28f8c81 100644 --- a/packages/app/src/pages/session/use-session-scroll-dock.test.ts +++ b/packages/app/src/pages/session/use-session-scroll-dock.test.ts @@ -298,6 +298,251 @@ describe("session scroll dock", () => { }) }) + test("runs dock height changes through the layout transaction callback while reading history", () => { + withResizeObserver((triggerResize) => { + createRoot((dispose) => { + const previousDockHeight = document.documentElement.style.getPropertyValue("--composer-dock-height") + const promptDock = makeMeasuredDiv(120) + const scroller = makeScroller({ clientHeight: 400, scrollHeight: 1000, scrollTop: 200 }) + const events: string[] = [] + + try { + const scrollDock = createSessionScrollDock({ + clearMessageHash: () => undefined, + clearActiveMessage: () => undefined, + fill: () => events.push("fill"), + runLayoutTransaction: (event) => { + events.push(`transaction:start:${event.kind}`) + event.mutate() + events.push("transaction:restore-anchor") + }, + }) + + scrollDock.setScrollRef(scroller.el) + scrollDock.setPromptDockRef(promptDock.el) + events.length = 0 + promptDock.setHeight(180) + triggerResize(promptDock.el) + + expect(events).toEqual(["transaction:start:dock-resize", "fill", "transaction:restore-anchor"]) + expect(document.documentElement.style.getPropertyValue("--composer-dock-height")).toBe("180px") + } finally { + dispose() + if (previousDockHeight) + document.documentElement.style.setProperty("--composer-dock-height", previousDockHeight) + else document.documentElement.style.removeProperty("--composer-dock-height") + } + }) + }) + }) + + test("lets the transaction issue the final bottom-follow command when pinned to latest", () => { + withResizeObserver((triggerResize) => { + createRoot((dispose) => { + const promptDock = makeMeasuredDiv(120) + const scroller = makeScroller({ clientHeight: 400, scrollHeight: 1000, scrollTop: 600 }) + const scrollCommandSink = createTimelineScrollCommandSink({ now: () => 600 }) + + try { + const scrollDock = createSessionScrollDock({ + clearMessageHash: () => undefined, + clearActiveMessage: () => undefined, + fill: () => undefined, + scrollCommandSink, + runLayoutTransaction: (event) => { + event.mutate() + event.restoreLatest("tx-dock-latest") + }, + }) + + scrollDock.setScrollRef(scroller.el) + scrollDock.setPromptDockRef(promptDock.el) + promptDock.setHeight(220) + triggerResize(promptDock.el) + + expect(scrollCommandSink.records()).toContainEqual( + expect.objectContaining({ + type: "dock-resize-bottom-follow", + transactionID: "tx-dock-latest", + transactionKind: "dock-resize", + }), + ) + } finally { + dispose() + document.documentElement.style.removeProperty("--composer-dock-height") + } + }) + }) + }) + + test("keeps dock resize bottom-follow recovery inside the active layout transaction", () => { + withResizeObserver((triggerResize) => { + createRoot((dispose) => { + const promptDock = makeMeasuredDiv(120) + const events: unknown[] = [] + let activeTransaction: { transactionID: string; transactionKind: "dock-resize" | "content-resize" } | undefined + const scroller = makeScroller({ clientHeight: 400, scrollHeight: 1000, scrollTop: 600 }) + const scrollCommandSink = createTimelineScrollCommandSink({ + activeTransaction: () => activeTransaction, + emitDiagnostic: (event) => { + events.push(event) + }, + }) + + try { + const scrollDock = createSessionScrollDock({ + clearMessageHash: () => undefined, + clearActiveMessage: () => undefined, + fill: () => undefined, + scrollCommandSink, + runLayoutTransaction: (event) => { + activeTransaction = { transactionID: "tx-dock-locked", transactionKind: event.kind } + try { + event.mutate() + event.restoreLatest("tx-dock-locked") + } finally { + activeTransaction = undefined + } + }, + }) + + scrollDock.setScrollRef(scroller.el) + scrollDock.setPromptDockRef(promptDock.el) + scrollDock.resumeScroll() + scroller.el.scrollTop = 0 + promptDock.setHeight(220) + triggerResize(promptDock.el) + + expect(scrollCommandSink.records().length).toBeGreaterThan(0) + expect(scrollCommandSink.records()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "dock-resize-bottom-follow", + transactionID: "tx-dock-locked", + transactionKind: "dock-resize", + }), + ]), + ) + expect(scrollCommandSink.records()).toEqual( + scrollCommandSink.records().map((record) => + expect.objectContaining({ + transactionID: "tx-dock-locked", + transactionKind: "dock-resize", + }), + ), + ) + expect(events).not.toContainEqual( + expect.objectContaining({ name: "session.timeline.layout_transaction_violation" }), + ) + } finally { + dispose() + document.documentElement.style.removeProperty("--composer-dock-height") + } + }) + }) + }) + + test("runs content resize through the layout transaction callback", () => { + withResizeObserver((triggerResize) => { + createRoot((dispose) => { + const content = makeMeasuredDiv(600) + const scroller = makeScroller({ clientHeight: 400, scrollHeight: 1200, scrollTop: 240 }) + const events: string[] = [] + + const scrollDock = createSessionScrollDock({ + clearMessageHash: () => undefined, + clearActiveMessage: () => undefined, + fill: () => events.push("fill"), + onContentResize: () => events.push("content-observed"), + runLayoutTransaction: (event) => { + events.push(`transaction:start:${event.kind}`) + event.mutate() + events.push("transaction:restore-anchor") + }, + }) + + scrollDock.setScrollRef(scroller.el) + scrollDock.setContentRef(content.el) + events.length = 0 + content.setHeight(720) + triggerResize(content.el) + + expect(events).toEqual([ + "transaction:start:content-resize", + "content-observed", + "fill", + "transaction:restore-anchor", + ]) + dispose() + }) + }) + }) + + test("keeps content resize bottom-follow recovery inside the active layout transaction", () => { + withResizeObserver((triggerResize) => { + createRoot((dispose) => { + const content = makeMeasuredDiv(600) + const events: unknown[] = [] + let activeTransaction: { transactionID: string; transactionKind: "dock-resize" | "content-resize" } | undefined + const scroller = makeScroller({ clientHeight: 400, scrollHeight: 1200, scrollTop: 800 }) + const scrollCommandSink = createTimelineScrollCommandSink({ + activeTransaction: () => activeTransaction, + emitDiagnostic: (event) => { + events.push(event) + }, + }) + + const scrollDock = createSessionScrollDock({ + clearMessageHash: () => undefined, + clearActiveMessage: () => undefined, + fill: () => undefined, + scrollCommandSink, + onContentResize: () => undefined, + runLayoutTransaction: (event) => { + activeTransaction = { transactionID: "tx-content-locked", transactionKind: event.kind } + try { + event.mutate() + event.restoreLatest("tx-content-locked") + } finally { + activeTransaction = undefined + } + }, + }) + + scrollDock.setScrollRef(scroller.el) + scrollDock.setContentRef(content.el) + scrollDock.resumeScroll() + scroller.el.scrollTop = 0 + content.setHeight(720) + triggerResize(content.el) + + expect(scrollCommandSink.records().length).toBeGreaterThan(0) + expect(scrollCommandSink.records()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "content-resize-bottom-follow", + transactionID: "tx-content-locked", + transactionKind: "content-resize", + }), + ]), + ) + expect(scrollCommandSink.records()).toEqual( + scrollCommandSink.records().map((record) => + expect.objectContaining({ + transactionID: "tx-content-locked", + transactionKind: "content-resize", + }), + ), + ) + expect(events).not.toContainEqual( + expect.objectContaining({ name: "session.timeline.layout_transaction_violation" }), + ) + + dispose() + }) + }) + }) + test("records a dock-resize bottom-follow command when resize needs a scroll write", () => { withResizeObserver((triggerResize) => { createRoot((dispose) => { diff --git a/packages/app/src/pages/session/use-session-scroll-dock.ts b/packages/app/src/pages/session/use-session-scroll-dock.ts index 9aec1a1b7..c5b9f249a 100644 --- a/packages/app/src/pages/session/use-session-scroll-dock.ts +++ b/packages/app/src/pages/session/use-session-scroll-dock.ts @@ -4,8 +4,10 @@ import { createStore } from "solid-js/store" import { createTimelineScrollCommandSink, type TimelineScrollCommandSink, + type TimelineScrollCommandTransaction, type TimelineScrollCommandType, } from "./timeline-scroll-command-sink" +import type { TimelineLayoutTransactionKind } from "./timeline-layout-transaction" export type SessionScrollState = { overflow: boolean @@ -13,6 +15,15 @@ export type SessionScrollState = { jump: boolean } +type SessionLayoutTransactionInput = { + kind: Extract + source: string + reason: string + stickToBottom: boolean + mutate: () => void + restoreLatest: (transactionID: string) => boolean +} + const BOTTOM_FOLLOW_LOCK_MS = 3_000 export function calculateSessionScrollState(input: { @@ -91,10 +102,12 @@ export function createSessionScrollDock(input: { dockKind: "composer" | "question" | "permission" | "todo" | "followup" | "revert" | "prompt" composerHeight: number previousComposerHeight: number + layoutTransactionHandled?: boolean scrollTop?: number distanceFromBottom?: number }) => void onContentResize?: (event: { scrollTop?: number; distanceFromBottom?: number }) => void + runLayoutTransaction?: (input: SessionLayoutTransactionInput) => void scrollCommandSink?: TimelineScrollCommandSink }) { const fallbackTimelineScrollCommandSink = createTimelineScrollCommandSink() @@ -181,8 +194,8 @@ export function createSessionScrollDock(input: { setScroll(next) } - const scheduleScrollState = (el: HTMLDivElement) => { - if (bottomFollowLocked()) { + const scheduleScrollState = (el: HTMLDivElement, options?: { recoverBottomLock?: boolean }) => { + if (options?.recoverBottomLock !== false && bottomFollowLocked()) { const next = calculateSessionScrollState({ clientHeight: el.clientHeight, scrollHeight: el.scrollHeight, @@ -205,6 +218,10 @@ export function createSessionScrollDock(input: { }) } + const scheduleTransactionScrollState = (el: HTMLDivElement) => { + scheduleScrollState(el, { recoverBottomLock: false }) + } + // A non-matching owner means the active lock belongs to an older session path. // Cancel it before it can call followBottom or schedule another scroll sample. const restoreBottomIfLocked = (owner?: string) => { @@ -217,6 +234,24 @@ export function createSessionScrollDock(input: { return true } + const restoreLatestThroughSink = (input: { + transaction: TimelineScrollCommandTransaction + type: Extract + source: string + reason: string + }) => { + if (!scroller) return false + scrollCommandSink().withTransaction(input.transaction).setScrollTop({ + element: scroller, + top: scroller.scrollHeight, + type: input.type, + source: input.source, + reason: input.reason, + }) + if (scroller) scheduleScrollState(scroller) + return true + } + const setScrollRef = (el: HTMLDivElement | undefined) => { scroller = el autoScroll.scrollRef(el) @@ -234,12 +269,34 @@ export function createSessionScrollDock(input: { if (el && scroller) scheduleScrollState(scroller) if (!el) return contentObserver = new ResizeObserver(() => { - input.onContentResize?.({ - scrollTop: scroller?.scrollTop, - distanceFromBottom: scroller ? scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop : undefined, - }) - if (scroller) scheduleScrollState(scroller) - input.fill() + const runContentMutation = (scheduleState: (el: HTMLDivElement) => void) => { + input.onContentResize?.({ + scrollTop: scroller?.scrollTop, + distanceFromBottom: scroller ? scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop : undefined, + }) + if (scroller) scheduleState(scroller) + input.fill() + } + + if (input.runLayoutTransaction && scroller) { + input.runLayoutTransaction({ + kind: "content-resize", + source: "use-session-scroll-dock/contentObserver", + reason: "content-resize", + stickToBottom: bottomFollowLockedFor(), + mutate: () => runContentMutation(scheduleTransactionScrollState), + restoreLatest: (transactionID) => + restoreLatestThroughSink({ + transaction: { transactionID, transactionKind: "content-resize" }, + type: "content-resize-bottom-follow", + source: "use-session-scroll-dock/layoutTransactionRestoreLatest", + reason: "content-resize", + }), + }) + return + } + + runContentMutation(scheduleScrollState) restoreBottomIfLocked() }) contentObserver.observe(el) @@ -250,22 +307,60 @@ export function createSessionScrollDock(input: { const dockKind = promptDockKind() const scrollTop = scroller?.scrollTop const distanceFromBottom = scroller ? scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop : undefined - dockHeight = syncComposerDockHeight({ - el: scroller, - previousDockHeight, - nextDockHeight: next, - userScrolled: autoScroll.userScrolled(), - setCssHeight: (value) => document.documentElement.style.setProperty("--composer-dock-height", `${value}px`), - forceScrollToBottom: () => autoScroll.forceScrollToBottom("dock-resize"), - scheduleScrollState, - fill: input.fill, - }) + let layoutTransactionHandled = false + const stickToBottom = scroller + ? shouldStickToBottomAfterDockResize({ + el: scroller, + userScrolled: autoScroll.userScrolled(), + previousDockHeight, + nextDockHeight: next, + }) + : false + const runDockMutation = (options: { + forceScrollToBottom: () => void + scheduleState: (el: HTMLDivElement) => void + }) => { + dockHeight = syncComposerDockHeight({ + el: scroller, + previousDockHeight, + nextDockHeight: next, + userScrolled: autoScroll.userScrolled(), + setCssHeight: (value) => document.documentElement.style.setProperty("--composer-dock-height", `${value}px`), + forceScrollToBottom: options.forceScrollToBottom, + scheduleScrollState: options.scheduleState, + fill: input.fill, + }) + } + + if (input.runLayoutTransaction && scroller && next !== previousDockHeight) { + layoutTransactionHandled = true + input.runLayoutTransaction({ + kind: "dock-resize", + source: "use-session-scroll-dock/updateDockHeight", + reason: dockKind, + stickToBottom, + mutate: () => runDockMutation({ forceScrollToBottom: () => {}, scheduleState: scheduleTransactionScrollState }), + restoreLatest: (transactionID) => + restoreLatestThroughSink({ + transaction: { transactionID, transactionKind: "dock-resize" }, + type: "dock-resize-bottom-follow", + source: "use-session-scroll-dock/layoutTransactionRestoreLatest", + reason: "dock-resize", + }), + }) + } else { + runDockMutation({ + forceScrollToBottom: () => autoScroll.forceScrollToBottom("dock-resize"), + scheduleState: scheduleScrollState, + }) + } if (dockHeight !== previousDockHeight) { try { input.onDockHeightChange?.({ dockKind, composerHeight: dockHeight, previousComposerHeight: previousDockHeight, + layoutTransactionHandled: layoutTransactionHandled || undefined, scrollTop, distanceFromBottom, }) diff --git a/packages/app/src/pages/session/use-session-timeline-interaction.test.ts b/packages/app/src/pages/session/use-session-timeline-interaction.test.ts new file mode 100644 index 000000000..3085be0ab --- /dev/null +++ b/packages/app/src/pages/session/use-session-timeline-interaction.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { shouldApplyTimelineRecoveryForObservation } from "./timeline-layout-recovery-policy" + +describe("session timeline interaction layout recovery", () => { + test("lets layout transactions own resize recovery while controller still observes resize", () => { + expect( + shouldApplyTimelineRecoveryForObservation({ + layoutTransactionActive: true, + observationType: "dock_resize", + }), + ).toBe(false) + expect( + shouldApplyTimelineRecoveryForObservation({ + layoutTransactionActive: true, + observationType: "content_resize", + }), + ).toBe(false) + }) + + test("skips dock resize recovery after an immediate transaction restore has already settled", () => { + expect( + shouldApplyTimelineRecoveryForObservation({ + layoutTransactionActive: false, + layoutTransactionHandled: true, + observationType: "dock_resize", + }), + ).toBe(false) + }) + + test("keeps non-transaction and non-resize recovery paths active", () => { + expect( + shouldApplyTimelineRecoveryForObservation({ + layoutTransactionActive: false, + observationType: "dock_resize", + }), + ).toBe(true) + expect( + shouldApplyTimelineRecoveryForObservation({ + layoutTransactionActive: true, + observationType: "scroll_sample", + }), + ).toBe(true) + }) +}) diff --git a/packages/app/src/pages/session/use-session-timeline-interaction.ts b/packages/app/src/pages/session/use-session-timeline-interaction.ts index fcfa71f6b..7212d75ea 100644 --- a/packages/app/src/pages/session/use-session-timeline-interaction.ts +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -1,5 +1,6 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" import { createEffect, createMemo, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" import { emitRendererDiagnostic } from "@/context/renderer-diagnostics" import { collectTimelineScrollMetrics, @@ -13,6 +14,11 @@ import { createSessionHistoryWindow } from "@/pages/session/use-session-history- import { createSessionScrollDock } from "@/pages/session/use-session-scroll-dock" import { createTimelineVirtualRows } from "@/pages/session/timeline-virtual-rows" import { createTimelineVirtualizerBridge } from "@/pages/session/timeline-virtualizer-bridge" +import { + createTimelineLayoutTransactionCoordinator, + type TimelineLayoutTransactionKind, +} from "@/pages/session/timeline-layout-transaction" +import { shouldApplyTimelineRecoveryForObservation } from "@/pages/session/timeline-layout-recovery-policy" import { createSessionTimelineScrollController, type TimelineRecovery, @@ -38,7 +44,13 @@ export function createSessionTimelineInteraction(input: { let clearMessageHash = () => {} let activeMessage!: ReturnType let historyBackfill: ReturnType | undefined + let historyWindow!: ReturnType let recoveryFrame: number | undefined + const [layoutTransactionState, setLayoutTransactionState] = createStore({ + active: false, + transactionID: undefined as string | undefined, + kind: undefined as TimelineLayoutTransactionKind | undefined, + }) const createScrollController = () => createSessionTimelineScrollController({ sessionOwner: input.sessionKey(), @@ -52,6 +64,13 @@ export function createSessionTimelineInteraction(input: { }) let scrollController = createScrollController() const scrollCommandSink = createTimelineScrollCommandSink({ + activeTransaction: () => + layoutTransactionState.active && layoutTransactionState.transactionID && layoutTransactionState.kind + ? { + transactionID: layoutTransactionState.transactionID, + transactionKind: layoutTransactionState.kind, + } + : undefined, emitDiagnostic: (event) => { void emitRendererDiagnostic(event).catch(() => {}) }, @@ -67,6 +86,75 @@ export function createSessionTimelineInteraction(input: { }), }) + const layoutTransactionCoordinator = createTimelineLayoutTransactionCoordinator({ + scheduleFrame: (callback) => requestAnimationFrame(callback), + cancelFrame: (handle) => cancelAnimationFrame(handle), + readMode: () => scrollController.state().mode, + sampleAnchor: () => { + const viewport = scrollDock.scroller() + const controllerState = scrollController.state() + const targetMessageID = + controllerState.lastSafePosition.kind === "target_message" + ? controllerState.lastSafePosition.messageID + : undefined + if (!viewport) return controllerState.lastSafePosition + return sampleTimelineSafePosition({ + viewport, + mode: controllerState.mode, + renderedStart: historyWindow.turnStart(), + renderedCount: historyWindow.renderedUserMessages().length, + newestMessageID: input.visibleUserMessages().at(-1)?.id, + targetMessageID, + }) + }, + restoreAnchor: (position, transactionID) => { + const viewport = scrollDock.scroller() + const restored = restoreTimelineSafePosition({ + viewport, + position, + scrollCommandSink: scrollCommandSink.withTransaction({ + transactionID, + transactionKind: layoutTransactionState.kind ?? "content-resize", + }), + }) + if (restored.ok && viewport) scrollDock.scheduleScrollState(viewport) + return restored.ok + }, + restoreLatest: () => false, + setStableBandActive: (active) => { + if (!active) setLayoutTransactionState({ active: false, transactionID: undefined, kind: undefined }) + }, + setTransactionState: (state) => { + if (state.active) { + setLayoutTransactionState({ active: true, transactionID: state.transactionID, kind: state.kind }) + return + } + setLayoutTransactionState({ active: false, transactionID: undefined, kind: undefined }) + }, + emitDiagnostic: (event) => { + void emitRendererDiagnostic({ + name: "session.timeline.layout_transaction", + route_session_id: input.routeSessionID(), + visible_session_id: input.sessionID(), + timeline_session_id: input.sessionID(), + monotonic_ms: event.monotonicMs, + data: { + transaction_id: event.transactionID, + transaction_kind: event.kind, + transaction_phase: event.phase, + transaction_status: event.violation ? "violation" : undefined, + mode: event.mode, + source: event.source, + reason: event.reason, + anchor_kind: event.anchorKind, + anchor_message_id: event.anchorMessageID, + fallback_frames: event.fallbackFrames, + violation: event.violation, + }, + }).catch(() => {}) + }, + }) + const cancelRecoveryFrame = () => { if (recoveryFrame === undefined) return cancelAnimationFrame(recoveryFrame) @@ -77,6 +165,7 @@ export function createSessionTimelineInteraction(input: { on( () => [input.sessionKey(), input.sessionID()] as const, () => { + layoutTransactionCoordinator.cancel() cancelRecoveryFrame() const previous = scrollController.state() scrollController.detach({ @@ -90,6 +179,7 @@ export function createSessionTimelineInteraction(input: { ) onCleanup(() => { + layoutTransactionCoordinator.cancel() cancelRecoveryFrame() const owner = scrollController.state() scrollController.detach({ @@ -121,6 +211,7 @@ export function createSessionTimelineInteraction(input: { previousDockHeight: event.previousComposerHeight, nextDockHeight: event.composerHeight, metrics: collectTimelineScrollMetrics(viewport), + layoutTransactionHandled: event.layoutTransactionHandled, }) } void emitRendererDiagnostic({ @@ -137,6 +228,16 @@ export function createSessionTimelineInteraction(input: { }, }) }, + runLayoutTransaction: (event) => { + layoutTransactionCoordinator.run({ + kind: event.kind, + source: event.source, + reason: event.reason, + mode: event.stickToBottom ? "following_latest" : undefined, + mutate: event.mutate, + restoreLatest: event.restoreLatest, + }) + }, }) const autoScroll = scrollDock.autoScroll const lockOwner = () => input.sessionKey() @@ -152,7 +253,7 @@ export function createSessionTimelineInteraction(input: { pauseAutoScroll: autoScroll.pause, }) - const historyWindow = createSessionHistoryWindow({ + historyWindow = createSessionHistoryWindow({ sessionID: input.sessionID, messagesReady: input.messagesReady, loaded: input.loadedMessages, @@ -193,6 +294,7 @@ export function createSessionTimelineInteraction(input: { }) const markScrollGesture = (target?: EventTarget | null) => { + layoutTransactionCoordinator.cancel() scrollDock.cancelBottomFollowLock() activeMessage.markScrollGesture(target) } @@ -209,6 +311,7 @@ export function createSessionTimelineInteraction(input: { } const navigateMessageByOffset = (offset: number) => { + layoutTransactionCoordinator.cancel() scrollDock.cancelBottomFollowLock() activeMessage.navigateMessageByOffset(offset) } @@ -239,7 +342,10 @@ export function createSessionTimelineInteraction(input: { } const onTimelineScrollIntent = (intent: TimelineScrollIntent): TimelineScrollControllerResult => { - if (shouldCancelBottomFollowLockForIntent(intent)) scrollDock.cancelBottomFollowLock() + if (shouldCancelBottomFollowLockForIntent(intent)) { + layoutTransactionCoordinator.cancel() + scrollDock.cancelBottomFollowLock() + } const result = scrollController.intent(intent) applyTimelineRecovery(result.recovery) return result @@ -269,7 +375,16 @@ export function createSessionTimelineInteraction(input: { } } const result = scrollController.observe(next) - applyTimelineRecovery(result.recovery) + if ( + shouldApplyTimelineRecoveryForObservation({ + layoutTransactionActive: layoutTransactionState.active, + layoutTransactionHandled: + "layoutTransactionHandled" in observation ? observation.layoutTransactionHandled : undefined, + observationType: observation.type, + }) + ) { + applyTimelineRecovery(result.recovery) + } return result } @@ -343,6 +458,9 @@ export function createSessionTimelineInteraction(input: { submitLatest, scheduleScrollState: scrollDock.scheduleScrollState, scrollDock, + layoutTransactionActive: () => layoutTransactionState.active, + layoutTransactionID: () => layoutTransactionState.transactionID, + layoutTransactionKind: () => layoutTransactionState.kind, setScrollRef: scrollDock.setScrollRef, markScrollGesture, navigateMessageByOffset, diff --git a/packages/app/src/testing/perf-metrics.test.ts b/packages/app/src/testing/perf-metrics.test.ts index 010550930..4d968b0f2 100644 --- a/packages/app/src/testing/perf-metrics.test.ts +++ b/packages/app/src/testing/perf-metrics.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test" -import { aggregatePerfRuns, comparePerfBaselines, comparePerfScenarioSummaries, PERF_COMMENT_MARKER, renderPerfBaselineComment, summarizePerfRun } from "./perf-metrics" +import { + aggregatePerfRuns, + comparePerfBaselines, + comparePerfScenarioSummaries, + PERF_COMMENT_MARKER, + renderPerfBaselineComment, + summarizePerfRun, +} from "./perf-metrics" function scenario(input: { branch?: string @@ -378,6 +385,22 @@ describe("perf metrics", () => { expect(result.failures).toContain("missing_head_scenario:low-end:session-timeline-recompute") }) + test("restricts confirmation comparisons to the originally failing scenarios", () => { + const base = [scenario({ branch: "base", scenario: "session-scroll-reading", interaction: 32 })] + const head = [ + scenario({ branch: "head", scenario: "session-scroll-reading", interaction: 40 }), + scenario({ branch: "head", scenario: "homepage-cold", frameMax: 183 }), + ] + + const result = comparePerfBaselines({ base, head, scenarioKeys: ["default:session-scroll-reading"] }) + + expect(result.pass).toBe(true) + expect(result.failures).toHaveLength(0) + expect(result.scenarios.map((entry) => `${entry.profile}:${entry.scenario}`)).toEqual([ + "default:session-scroll-reading", + ]) + }) + test("keeps low-end moderate regressions warning-only", () => { const result = comparePerfScenarioSummaries({ scenario: "session-timeline-recompute", diff --git a/packages/app/src/testing/perf-metrics.ts b/packages/app/src/testing/perf-metrics.ts index eaf582283..ca83c09a6 100644 --- a/packages/app/src/testing/perf-metrics.ts +++ b/packages/app/src/testing/perf-metrics.ts @@ -306,10 +306,14 @@ export function comparePerfScenarioSummaries(input: { if (interactionWorstRegressed) { warnings.push("interaction_ms_worst_delta") } - if (interactionWorstRegressed && input.head.interaction_ms_worst >= lowEndCatastrophicThresholds.interactionMsWorst) { + if ( + interactionWorstRegressed && + input.head.interaction_ms_worst >= lowEndCatastrophicThresholds.interactionMsWorst + ) { failures.push("interaction_ms_worst") } - const longTaskRegressed = input.head.long_task_max_ms > input.base.long_task_max_ms + lowEndWarningThresholds.longTaskMaxMs + const longTaskRegressed = + input.head.long_task_max_ms > input.base.long_task_max_ms + lowEndWarningThresholds.longTaskMaxMs if (longTaskRegressed) { warnings.push("long_task_max_ms_delta") } @@ -322,7 +326,8 @@ export function comparePerfScenarioSummaries(input: { if (input.head.frame_gap_p95_ms > input.base.frame_gap_p95_ms + lowEndWarningThresholds.frameGapP95Ms) { warnings.push("frame_gap_p95_ms") } - const frameGapMaxRegressed = input.head.frame_gap_max_ms > input.base.frame_gap_max_ms + lowEndWarningThresholds.frameGapMaxMs + const frameGapMaxRegressed = + input.head.frame_gap_max_ms > input.base.frame_gap_max_ms + lowEndWarningThresholds.frameGapMaxMs if (frameGapMaxRegressed) { warnings.push("frame_gap_max_ms_delta") } @@ -389,7 +394,12 @@ export function comparePerfScenarioSummaries(input: { failures.push("cls_delta") } - addAbsoluteWarning(warnings, "interaction_ms_worst", input.head.interaction_ms_worst, perfAbsoluteWarnings.interactionMsWorst) + addAbsoluteWarning( + warnings, + "interaction_ms_worst", + input.head.interaction_ms_worst, + perfAbsoluteWarnings.interactionMsWorst, + ) addAbsoluteWarning(warnings, "tbt_ms", input.head.tbt_ms, perfAbsoluteWarnings.tbtMs) addAbsoluteWarning(warnings, "cls", input.head.cls, perfAbsoluteWarnings.cls) addAbsoluteWarning(warnings, "fcp_ms", input.head.fcp_ms, perfAbsoluteWarnings.fcpMs) @@ -413,14 +423,17 @@ function scenarioKey(input: { profile?: PerfProfile; scenario: string }) { export function comparePerfBaselines(input: { base: PerfScenarioSummary[] head: PerfScenarioSummary[] + scenarioKeys?: string[] }): PerfBaselineComparison { const failures: string[] = [] const warnings: string[] = [] const scenarios: PerfScenarioComparison[] = [] const headByScenario = new Map(input.head.map((scenario) => [scenarioKey(scenario), scenario])) + const requestedScenarioKeys = input.scenarioKeys ? new Set(input.scenarioKeys) : undefined for (const baseScenario of input.base) { const key = scenarioKey(baseScenario) + if (requestedScenarioKeys && !requestedScenarioKeys.has(key)) continue const headScenario = headByScenario.get(key) if (!headScenario) { failures.push(`missing_head_scenario:${key}`) @@ -438,6 +451,7 @@ export function comparePerfBaselines(input: { for (const headScenario of input.head) { const key = scenarioKey(headScenario) + if (requestedScenarioKeys && !requestedScenarioKeys.has(key)) continue if (!input.base.some((scenario) => scenarioKey(scenario) === key)) { failures.push(`missing_base_scenario:${key}`) } diff --git a/packages/app/src/testing/timeline.test.ts b/packages/app/src/testing/timeline.test.ts new file mode 100644 index 000000000..badd9eed5 --- /dev/null +++ b/packages/app/src/testing/timeline.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import { + bindTimelineDriver, + timelineDriverDefaultTestRuntime, + timelineEvent, + timelineDriverEnabled, + type TimelineWindow, +} from "./timeline" + +describe("timeline e2e driver", () => { + test("stays disabled outside explicit test runtime even when the window flag is set", () => { + const win = { __opencode_e2e: { timeline: { enabled: true } } } as TimelineWindow + + expect(timelineDriverEnabled({ testRuntime: false, windowRef: win })).toBe(false) + }) + + test("requires the window flag inside explicit test runtime", () => { + const win = { __opencode_e2e: { timeline: { enabled: true } } } as TimelineWindow + + expect(timelineDriverEnabled({ testRuntime: true, windowRef: win })).toBe(true) + expect(timelineDriverEnabled({ testRuntime: true, windowRef: {} as TimelineWindow })).toBe(false) + }) + + test("uses DEV or TEST as the default runtime guard", () => { + expect(timelineDriverDefaultTestRuntime({ DEV: false, TEST: false })).toBe(false) + expect(timelineDriverDefaultTestRuntime({ DEV: true, TEST: false })).toBe(true) + expect(timelineDriverDefaultTestRuntime({ DEV: false, TEST: true })).toBe(true) + }) + + test("binds the reveal listener inside the centralized test driver", () => { + const listeners = new Map void>() + const win = { + __opencode_e2e: { timeline: { enabled: true } }, + addEventListener(name: string, handler: EventListener) { + listeners.set(name, handler as (event: Event) => void) + }, + removeEventListener(name: string, handler: EventListener) { + if (listeners.get(name) === handler) listeners.delete(name) + }, + } as unknown as TimelineWindow + let reveals = 0 + + const cleanup = bindTimelineDriver({ + testRuntime: true, + timelineSessionID: () => "session-1", + revealCached: () => { + reveals += 1 + }, + windowRef: win, + }) + + listeners.get(timelineEvent)?.(new CustomEvent(timelineEvent, { detail: { action: "reveal-cached" } })) + listeners.get(timelineEvent)?.( + new CustomEvent(timelineEvent, { detail: { action: "reveal-cached", sessionID: "other-session" } }), + ) + cleanup() + + expect(reveals).toBe(1) + expect(listeners.has(timelineEvent)).toBe(false) + }) + + test("does not bind the listener outside explicit test runtime", () => { + const listeners = new Map() + const win = { + __opencode_e2e: { timeline: { enabled: true } }, + addEventListener(name: string, handler: EventListener) { + listeners.set(name, handler) + }, + removeEventListener() {}, + } as unknown as TimelineWindow + + bindTimelineDriver({ + testRuntime: false, + timelineSessionID: () => undefined, + revealCached: () => {}, + windowRef: win, + }) + + expect(listeners.has(timelineEvent)).toBe(false) + }) +}) diff --git a/packages/app/src/testing/timeline.ts b/packages/app/src/testing/timeline.ts new file mode 100644 index 000000000..f094979d1 --- /dev/null +++ b/packages/app/src/testing/timeline.ts @@ -0,0 +1,67 @@ +import { onCleanup, onMount } from "solid-js" + +export const timelineEvent = "opencode:e2e:timeline" + +export type TimelineDriverAction = "reveal-cached" + +export type TimelineDriverEvent = CustomEvent<{ + action: TimelineDriverAction + sessionID?: string +}> + +export type TimelineWindow = Window & { + __opencode_e2e?: { + timeline?: { + enabled?: boolean + } + } +} + +type TimelineEventTarget = Pick + +export const timelineDriverDefaultTestRuntime = (env: { DEV?: boolean; TEST?: boolean } = import.meta.env) => + env.DEV || env.TEST + +export const timelineDriverEnabled = (input?: { testRuntime?: boolean; windowRef?: TimelineWindow }) => { + if (!input?.testRuntime) return false + const win = input.windowRef ?? (typeof window === "undefined" ? undefined : (window as TimelineWindow)) + return win?.__opencode_e2e?.timeline?.enabled === true +} + +export const bindTimelineDriver = (input: { + testRuntime?: boolean + timelineSessionID: () => string | undefined + revealCached: () => void + windowRef?: TimelineWindow & TimelineEventTarget +}) => { + if (!input.testRuntime) return () => {} + const win = + input.windowRef ?? (typeof window === "undefined" ? undefined : (window as TimelineWindow & TimelineEventTarget)) + if (!win) return () => {} + + const handleTimelineDriver = (event: Event) => { + if (!timelineDriverEnabled({ testRuntime: input.testRuntime, windowRef: win })) return + const detail = (event as TimelineDriverEvent).detail + if (detail?.sessionID && detail.sessionID !== input.timelineSessionID()) return + if (detail?.action === "reveal-cached") input.revealCached() + } + + win.addEventListener(timelineEvent, handleTimelineDriver) + return () => win.removeEventListener(timelineEvent, handleTimelineDriver) +} + +export function TimelineE2EDriverBoundary(props: { + timelineSessionID: () => string | undefined + revealCached: () => void +}) { + onMount(() => { + const cleanupTimelineDriver = bindTimelineDriver({ + testRuntime: timelineDriverDefaultTestRuntime(), + timelineSessionID: props.timelineSessionID, + revealCached: props.revealCached, + }) + onCleanup(cleanupTimelineDriver) + }) + + return null +}