From 7a45ff89f93d22e7ec283a650520c78b79775395 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 15 Jun 2026 07:59:43 +0200 Subject: [PATCH] fix(app): paint cached session tabs at the latest message --- .../app/e2e/smoke/session-timeline.spec.ts | 95 +++++++++++++++++++ packages/app/src/pages/session.tsx | 18 +++- .../src/pages/session/message-timeline.tsx | 38 +++++++- 3 files changed, 145 insertions(+), 6 deletions(-) diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 526e978151b..ba6ed5856b2 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -104,6 +104,93 @@ test.describe("smoke: session timeline", () => { .toBeLessThanOrEqual(1) }) + test("paints cached session tabs at the latest message", async ({ page }) => { + await mockOpenCodeServer(page, { + sessions: fixture.sessions, + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), + }) + await configureSmokePage(page, fixture.directory) + await page.addInitScript( + ({ dirBase64, sourceID, targetID }) => { + localStorage.setItem( + "opencode.global.dat:tabs", + JSON.stringify( + [sourceID, targetID].map((sessionId) => ({ + type: "session", + server: "http://127.0.0.1:4096", + dirBase64, + sessionId, + })), + ), + ) + }, + { dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID }, + ) + + await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`) + await expectSessionTitle(page, fixture.expected.targetTitle) + await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle) + + const destination = fixture.messages[fixture.targetID].map((message) => message.info.id) + const last = fixture.expected.targetMessageIDs.at(-1)! + await page.evaluate( + ({ destination, last }) => { + const ids = new Set(destination) + const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = [] + let running = true + const sample = () => { + if (!running) return + setTimeout(() => { + if (!running) return + const root = [...document.querySelectorAll(".scroll-view__viewport")].find((element) => + element.querySelector("[data-timeline-row]"), + ) + if (root) { + const view = root.getBoundingClientRect() + const visible = [...root.querySelectorAll("[data-message-id]")] + .filter((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > view.top && rect.top < view.bottom + }) + .map((element) => element.dataset.messageId!) + .filter((id) => ids.has(id)) + const bottom = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect() + samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) + } + requestAnimationFrame(sample) + }, 0) + } + ;(window as Window & { __sessionTabPaint?: { samples: typeof samples; stop: () => void } }).__sessionTabPaint = { + samples, + stop: () => { + running = false + }, + } + requestAnimationFrame(sample) + }, + { destination, last }, + ) + + await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle) + await page.waitForFunction(() => + (window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }).__sessionTabPaint?.samples.some( + (sample) => sample.ids.length > 0, + ), + ) + const first = await page.evaluate(() => { + const probe = (window as Window & { + __sessionTabPaint?: { samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>; stop: () => void } + }).__sessionTabPaint! + probe.stop() + return probe.samples.find((sample) => sample.ids.length > 0) + }) + expect(first?.last).toBe(true) + expect(Math.abs(first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1) + }) + test("renders seeded timeline in order while paging through history", async ({ page }) => { const errors = trackPageErrors(page) await mockOpenCodeServer(page, { @@ -501,6 +588,14 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin await expectSessionTitle(page, expectedTitle) } +async function switchTitlebarSession(page: Page, sessionID: string, title: string) { + const href = `/${base64Encode(fixture.directory)}/session/${sessionID}` + const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() + await expect(tab).toBeVisible() + await tab.click() + await expectSessionTitle(page, title) +} + async function expectSessionReady(page: Page) { await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i })) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e9519684a66..a06e7c17d60 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1165,6 +1165,16 @@ export default function Page() { working: () => true, overflowAnchor: "none", }) + createEffect( + on( + () => params.id, + (id, previous) => { + if (!id || !previous || id === previous) return + if (location.hash || store.messageId || ui.pendingMessage) return + autoScroll.resume() + }, + ), + ) let scrollStateFrame: number | undefined let scrollStateTarget: HTMLDivElement | undefined @@ -1763,8 +1773,9 @@ export default function Page() { - - + {(_id) => ( + { scrollToEnd = fn }} - /> + /> + )} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index db1e3d09713..b2a8e7c99a8 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -16,7 +16,7 @@ import { createStore, produce } from "solid-js/store" import { Dynamic } from "solid-js/web" import { useNavigate } from "@solidjs/router" import { useMutation } from "@tanstack/solid-query" -import { createVirtualizer, defaultRangeExtractor, elementScroll } from "@tanstack/solid-virtual" +import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual" import { Accordion } from "@opencode-ai/ui/accordion" import { Button } from "@opencode-ai/ui/button" import { Card } from "@opencode-ai/ui/card" @@ -82,6 +82,10 @@ type FramedTimelineRow = Exclude type TimelineRowByTag = Extract const timelineFallbackItemSize = 60 +const timelineCache = new Map< + string, + { measurements: VirtualItem[]; toolOpen: Record } +>() function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) { if (!previous?.length) return rows @@ -274,6 +278,8 @@ export function MessageTimeline(props: { const dialog = useDialog() const language = useLanguage() const { params, sessionKey } = useSessionKey() + const ownerSessionKey = sessionKey() + const cached = timelineCache.get(ownerSessionKey) const platform = usePlatform() const [listRoot, setListRoot] = createSignal() @@ -467,13 +473,20 @@ export function MessageTimeline(props: { const rows = messageRowMemos().flatMap((memo) => memo()) return reuseTimelineRows(previous, rows) }) - const [toolOpen, setToolOpen] = createStore>({}) + const [toolOpen, setToolOpen] = createStore>(cached?.toolOpen ?? {}) + const initialMeasurements = cached?.measurements + const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length ? 6 : 50) + const prepareScrollOverscan = () => { + if (renderOverscan() < 50) setRenderOverscan(50) + } let virtualContent: HTMLDivElement | undefined const virtualizer = createVirtualizer({ get count() { return timelineRows().length }, getScrollElement: () => listRoot() ?? null, + initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0), + initialMeasurementsCache: initialMeasurements, estimateSize: () => timelineFallbackItemSize, scrollToFn: (offset, options, instance) => { // Expose the computed range before core writes an anchor correction so the browser does not clamp it to the old height. @@ -497,7 +510,8 @@ export function MessageTimeline(props: { rangeExtractor: (range) => { const id = activeMessageID() const active = id ? timelineRows().findLastIndex((row) => "userMessageID" in row && row.userMessageID === id) : -1 - return [...new Set([...defaultRangeExtractor(range), ...(active < 0 ? [] : [active])])].sort((a, b) => a - b) + const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() }) + return [...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b) }, }) virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => @@ -534,6 +548,18 @@ export function MessageTimeline(props: { props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor }) }) + onMount(() => { + const expand = () => { + const next = Math.min(50, renderOverscan() + 8) + setRenderOverscan(next) + if (next < 50) requestAnimationFrame(() => setTimeout(expand, 0)) + } + requestAnimationFrame(() => { + if (props.shouldAnchorBottom()) virtualizer.scrollToEnd() + if (renderOverscan() < 50) setTimeout(expand, 0) + }) + }) + let bottomAnchorSessionKey = "" let bottomAnchorFrame: number | undefined @@ -565,6 +591,9 @@ export function MessageTimeline(props: { }) onCleanup(() => { + timelineCache.delete(ownerSessionKey) + timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } }) + while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!) if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame) props.setRevealMessage?.(() => {}) props.setScrollToEnd?.(() => {}) @@ -605,6 +634,7 @@ export function MessageTimeline(props: { const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => { + prepareScrollOverscan() if (!prependLoading) clearPrependAnchor() const root = event.currentTarget const delta = normalizeWheelDelta({ @@ -617,6 +647,7 @@ export function MessageTimeline(props: { } const handleListTouchStart = (event: TouchEvent) => { + prepareScrollOverscan() if (!prependLoading) clearPrependAnchor() touchGesture = event.touches[0]?.clientY } @@ -643,6 +674,7 @@ export function MessageTimeline(props: { } const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => { + prepareScrollOverscan() if (!prependLoading) clearPrependAnchor() if (event.target !== event.currentTarget) return props.onMarkScrollGesture(event.currentTarget)