diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index c062a91ee..082cb9ed0 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -5,6 +5,7 @@ export const terminalSelector = `${desktopTerminalSelector}, ${mobileTerminalSel export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]' export const sessionTurnListSelector = '[data-slot="session-turn-list"]' export const sessionMessageItemSelector = "[data-message-id]" +export const scrollViewportSelector = '[data-component="scroll-viewport"]' export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]' export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]' export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]' diff --git a/packages/app/e2e/session/session-scroll-position.spec.ts b/packages/app/e2e/session/session-scroll-position.spec.ts index 6e031025d..1fc883ba6 100644 --- a/packages/app/e2e/session/session-scroll-position.spec.ts +++ b/packages/app/e2e/session/session-scroll-position.spec.ts @@ -1,7 +1,13 @@ import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" import { withSession } from "../actions" -import { promptSelector, sessionItemSelector, sessionMessageItemSelector, sessionTurnListSelector } from "../selectors" +import { + promptSelector, + scrollViewportSelector, + sessionItemSelector, + sessionMessageItemSelector, + sessionTurnListSelector, +} from "../selectors" import { createSdk } from "../utils" type Sdk = ReturnType @@ -15,10 +21,15 @@ type TimelineMetrics = { distanceFromBottom: number } +type TimelineScrollSample = TimelineMetrics & { + at: number + url: string +} + function timelineMetrics(page: Page) { - return page.evaluate((turnListSelector) => { + return page.evaluate(({ scrollViewportSelector, turnListSelector }) => { const list = document.querySelector(turnListSelector) - const viewport = list?.closest(".scroll-view__viewport") + const viewport = list?.closest(scrollViewportSelector) if (!(viewport instanceof HTMLElement)) return null return { top: viewport.scrollTop, @@ -26,7 +37,7 @@ function timelineMetrics(page: Page) { client: viewport.clientHeight, distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop, } - }, sessionTurnListSelector) as Promise + }, { scrollViewportSelector, turnListSelector: sessionTurnListSelector }) as Promise } async function expectTimelineMetrics(page: Page) { @@ -36,16 +47,94 @@ async function expectTimelineMetrics(page: Page) { } async function scrollTimelineToBottom(page: Page) { - const found = await page.evaluate((turnListSelector) => { + const found = await page.evaluate(({ scrollViewportSelector, turnListSelector }) => { const list = document.querySelector(turnListSelector) - const viewport = list?.closest(".scroll-view__viewport") + const viewport = list?.closest(scrollViewportSelector) if (!(viewport instanceof HTMLElement)) return false viewport.scrollTop = viewport.scrollHeight return true - }, sessionTurnListSelector) + }, { scrollViewportSelector, turnListSelector: sessionTurnListSelector }) expect(found, "session timeline viewport should exist").toBe(true) } +async function installTimelineScrollProbe(page: Page) { + await page.evaluate( + ({ maxSamples, scrollViewportSelector, turnListSelector }) => { + const read = () => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return null + return { + at: performance.now(), + url: window.location.href, + top: viewport.scrollTop, + height: viewport.scrollHeight, + client: viewport.clientHeight, + distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop, + } + } + const changed = (a: NonNullable>, b: NonNullable>) => + a.url !== b.url || + a.top !== b.top || + a.height !== b.height || + a.client !== b.client || + a.distanceFromBottom !== b.distanceFromBottom + const samples: NonNullable>[] = [] + const push = () => { + const next = read() + if (!next) return + const prev = samples[samples.length - 1] + if (prev && !changed(prev, next)) return + if (samples.length < maxSamples) samples.push(next) + } + push() + let frame = requestAnimationFrame(function tick() { + push() + frame = requestAnimationFrame(tick) + }) + const observer = new MutationObserver(push) + observer.observe(document.body, { childList: true, subtree: true }) + const first = read() + const viewport = first + ? document.querySelector(turnListSelector)?.closest(scrollViewportSelector) + : undefined + if (viewport instanceof HTMLElement) viewport.addEventListener("scroll", push, { passive: true }) + const win = window as typeof window & { + __opencode_e2e?: Record & { + timelineScrollProbe?: { stop: () => unknown } + } + } + win.__opencode_e2e = { + ...(win.__opencode_e2e ?? {}), + timelineScrollProbe: { + stop() { + cancelAnimationFrame(frame) + observer.disconnect() + if (viewport instanceof HTMLElement) viewport.removeEventListener("scroll", push) + push() + delete win.__opencode_e2e?.timelineScrollProbe + return samples + }, + }, + } + }, + { maxSamples: 256, scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) +} + +async function stopTimelineScrollProbe(page: Page) { + return page.evaluate(() => { + const win = window as typeof window & { + __opencode_e2e?: { + timelineScrollProbe?: { stop: () => unknown } + } + } + const probe = win.__opencode_e2e?.timelineScrollProbe + if (!probe) throw new Error("timeline scroll probe was not installed") + return probe.stop() + }) as Promise +} + async function sendVisiblePrompt(input: { page: Page; text: string }) { const prompt = input.page.locator(promptSelector) await expect(prompt).toBeVisible() @@ -169,6 +258,60 @@ test("keeps the latest turn in view when sending from an old message hash", asyn }) }) +test("does not jump to the top after sending from an old message hash", async ({ page, project }) => { + test.setTimeout(120_000) + + await project.open() + const sdk = project.sdk + + await withSession(sdk, `e2e send top guard ${Date.now()}`, async (session) => { + project.trackSession(session.id) + + await seedSessionTurns({ sdk, sessionID: session.id, count: 18 }) + + await project.gotoSession(session.id) + await expect(page.locator(sessionMessageItemSelector)).toHaveCount(INITIAL_SESSION_WINDOW_MESSAGES, { + timeout: 30_000, + }) + await scrollTimelineToBottom(page) + await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeLessThan(20) + + const ids = await page.locator(sessionMessageItemSelector).evaluateAll((items) => + items.map((item) => (item instanceof HTMLElement ? item.dataset.messageId : undefined)).filter(Boolean), + ) + const oldID = ids[1] + if (!oldID) throw new Error("expected an older rendered message id") + + await page.goto(`${page.url()}#message-${oldID}`) + await expect(page.locator(`#message-${oldID}`)).toBeVisible() + await expect.poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom).toBeGreaterThan(100) + + await installTimelineScrollProbe(page) + let samples: TimelineScrollSample[] = [] + const sendStartedAt = await page.evaluate(() => performance.now()) + try { + await sendVisiblePrompt({ page, text: `top guard ${Date.now()}` }) + await expect.poll(() => page.url()).not.toContain("#message-") + await expect + .poll(async () => (await expectTimelineMetrics(page)).distanceFromBottom, { timeout: 30_000 }) + .toBeLessThan(40) + } finally { + samples = await stopTimelineScrollProbe(page) + } + + expect(samples.length).toBeGreaterThan(0) + const relevantSamples = samples.filter((sample) => sample.at >= sendStartedAt) + expect(relevantSamples.length).toBeGreaterThan(0) + const topJumps = relevantSamples.filter( + (sample) => + sample.height > sample.client + 100 && + sample.top < 20 && + sample.distanceFromBottom > 100, + ) + expect(topJumps).toEqual([]) + }) +}) + test("renders the full initial session window when switching sessions", async ({ page, project }) => { test.setTimeout(120_000) diff --git a/packages/app/src/pages/session/session-auto-scroll.test.ts b/packages/app/src/pages/session/session-auto-scroll.test.ts new file mode 100644 index 000000000..8e755aa57 --- /dev/null +++ b/packages/app/src/pages/session/session-auto-scroll.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { createAutoScroll } from "@opencode-ai/ui/hooks" +import { createRoot } from "solid-js" + +describe("session auto scroll", () => { + test("disables overflow anchoring before forcing the timeline to bottom", () => { + createRoot((dispose) => { + const el = document.createElement("div") + let top = 500 + let anchorAtScroll = "" + + Object.defineProperties(el, { + clientHeight: { value: 100, configurable: true }, + scrollHeight: { value: 1000, configurable: true }, + scrollTop: { + configurable: true, + get: () => top, + set: (value) => { + anchorAtScroll = el.style.overflowAnchor + top = value + }, + }, + }) + + const autoScroll = createAutoScroll({ + working: () => true, + overflowAnchor: "dynamic", + }) + + autoScroll.scrollRef(el) + autoScroll.pause() + el.style.overflowAnchor = "auto" + + expect(autoScroll.userScrolled()).toBe(true) + expect(el.style.overflowAnchor).toBe("auto") + + autoScroll.forceScrollToBottom() + + expect(anchorAtScroll).toBe("none") + expect(top).toBe(1000) + + dispose() + }) + }) +}) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 3ff00f117..190b23e51 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -194,6 +194,7 @@ export function ScrollView(props: ScrollViewProps) { {/* Viewport */}
{ updateThumb() diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index cb76ca828..6c81424eb 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -34,6 +34,22 @@ export function createAutoScroll(options: AutoScrollOptions) { return el.scrollHeight - el.clientHeight > 1 } + const updateOverflowAnchor = (el: HTMLElement) => { + const mode = options.overflowAnchor ?? "dynamic" + + if (mode === "none") { + el.style.overflowAnchor = "none" + return + } + + if (mode === "auto") { + el.style.overflowAnchor = "auto" + return + } + + el.style.overflowAnchor = store.userScrolled ? "auto" : "none" + } + // Browsers can dispatch scroll events asynchronously. If new content arrives // between us calling `scrollTo()` and the subsequent `scroll` event firing, // the handler can see a non-zero `distanceFromBottom` and incorrectly assume @@ -79,9 +95,13 @@ export function createAutoScroll(options: AutoScrollOptions) { const scrollToBottom = (force: boolean) => { if (!force && !active()) return - if (force && store.userScrolled) setStore("userScrolled", false) - const el = store.scrollRef + + if (force && store.userScrolled) { + setStore("userScrolled", false) + if (el) updateOverflowAnchor(el) + } + if (!el) return if (!force && store.userScrolled) return @@ -153,22 +173,6 @@ export function createAutoScroll(options: AutoScrollOptions) { } } - const updateOverflowAnchor = (el: HTMLElement) => { - const mode = options.overflowAnchor ?? "dynamic" - - if (mode === "none") { - el.style.overflowAnchor = "none" - return - } - - if (mode === "auto") { - el.style.overflowAnchor = "auto" - return - } - - el.style.overflowAnchor = store.userScrolled ? "auto" : "none" - } - createResizeObserver( () => store.contentRef, () => {