fix(app): paint cached session tabs at the latest message

This commit is contained in:
LukeParkerDev 2026-06-15 07:59:43 +02:00
parent 77848d3a55
commit 7a45ff89f9
3 changed files with 145 additions and 6 deletions

View file

@ -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<HTMLElement>(".scroll-view__viewport")].find((element) =>
element.querySelector("[data-timeline-row]"),
)
if (root) {
const view = root.getBoundingClientRect()
const visible = [...root.querySelectorAll<HTMLElement>("[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<HTMLElement>('[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 }))
}

View file

@ -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() {
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
<Show when={messagesReady() ? params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
@ -1799,7 +1810,8 @@ export default function Page() {
setScrollToEnd={(fn) => {
scrollToEnd = fn
}}
/>
/>
)}
</Show>
</Match>
<Match when={true}>

View file

@ -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<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
const timelineFallbackItemSize = 60
const timelineCache = new Map<
string,
{ measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }
>()
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<HTMLDivElement>()
@ -467,13 +473,20 @@ export function MessageTimeline(props: {
const rows = messageRowMemos().flatMap((memo) => memo())
return reuseTimelineRows(previous, rows)
})
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(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<HTMLDivElement, HTMLDivElement>({
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)