perf(app): virtualize session timeline rows (#26949)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Luke Parker
2026-05-18 03:40:52 +00:00
committed by GitHub
co-authored by Brendan Allan
parent e85119aa64
commit 5452ab6db7
9 changed files with 1618 additions and 883 deletions
+63 -201
View File
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { checksum } from "@opencode-ai/core/util/encode"
import { useSearchParams } from "@solidjs/router"
import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
@@ -75,7 +75,6 @@ type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
loaded: () => number
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
@@ -85,205 +84,74 @@ type SessionHistoryWindowInput = {
scroller: () => HTMLDivElement | undefined
}
/**
* Maintains the rendered history window for a session timeline.
*
* It keeps initial paint bounded to recent turns, reveals cached turns in
* small batches while scrolling upward, and prefetches older history near top.
*/
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const turnInit = 10
const turnBatch = 8
const turnScrollThreshold = 200
const turnPrefetchBuffer = 16
const prefetchCooldownMs = 400
const prefetchNoGrowthLimit = 2
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
const historyScrollThreshold = 200
let shiftFrame: number | undefined
const [state, setState] = createStore({
turnID: undefined as string | undefined,
turnStart: 0,
prefetchUntil: 0,
prefetchNoGrowth: 0,
shift: false,
})
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
const turnStart = createMemo(() => {
const id = input.sessionID()
const len = input.visibleUserMessages().length
if (!id || len <= 0) return 0
if (state.turnID !== id) return initialTurnStart(len)
if (state.turnStart <= 0) return 0
if (state.turnStart >= len) return initialTurnStart(len)
return state.turnStart
const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
equals: same,
})
const setTurnStart = (start: number) => {
const id = input.sessionID()
const next = start > 0 ? start : 0
if (!id) {
setState({ turnID: undefined, turnStart: next })
return
}
setState({ turnID: id, turnStart: next })
const cancelShiftReset = () => {
if (shiftFrame === undefined) return
cancelAnimationFrame(shiftFrame)
shiftFrame = undefined
}
const renderedUserMessages = createMemo(
() => {
const msgs = input.visibleUserMessages()
const start = turnStart()
if (start <= 0) return msgs
return msgs.slice(start)
},
emptyUserMessages,
{
equals: same,
},
)
const preserveScroll = (fn: () => void) => {
const el = input.scroller()
if (!el) {
fn()
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
const scheduleShiftReset = () => {
cancelShiftReset()
shiftFrame = requestAnimationFrame(() => {
shiftFrame = undefined
setState("shift", false)
})
}
const backfillTurns = () => {
const start = turnStart()
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
preserveScroll(() => setTurnStart(nextStart))
}
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
const loadAndReveal = async () => {
const id = input.sessionID()
if (!id) return
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
let loaded = input.loaded()
if (start > 0) setTurnStart(0)
if (!input.historyMore() || input.historyLoading()) return
let afterVisible = beforeVisible
let added = 0
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
afterVisible = input.visibleUserMessages().length
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded
if (afterVisible > beforeVisible) break
if (raw <= 0) break
if (!input.historyMore()) break
}
if (added <= 0) return
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
const growth = afterVisible - beforeVisible
if (growth <= 0) return
if (turnStart() !== 0) return
const target = Math.min(afterVisible, beforeVisible + turnBatch)
setTurnStart(Math.max(0, afterVisible - target))
}
/** Scroll/prefetch path: fetch older history from server. */
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
const fetchOlderMessages = async () => {
const id = input.sessionID()
if (!id) return
if (!input.historyMore() || input.historyLoading()) return
if (opts?.prefetch) {
const now = Date.now()
if (state.prefetchUntil > now) return
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
setState("prefetchUntil", now + prefetchCooldownMs)
}
const start = turnStart()
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
const beforeVisible = input.visibleUserMessages().length
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
let loaded = input.loaded()
let added = 0
let growth = 0
cancelShiftReset()
setState("shift", true)
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded
growth = input.visibleUserMessages().length - beforeVisible
if (growth > 0) break
if (raw <= 0) break
if (opts?.prefetch) break
if (!input.historyMore()) break
}
const afterVisible = input.visibleUserMessages().length
if (opts?.prefetch) {
setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1)
} else if (added > 0 && state.prefetchNoGrowth) {
setState("prefetchNoGrowth", 0)
}
if (added <= 0) return
if (growth <= 0) return
if (opts?.prefetch) {
const current = turnStart()
preserveScroll(() => setTurnStart(current + growth))
if (growth > 0) {
scheduleShiftReset()
return
}
if (turnStart() !== start) return
const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered)
const target = Math.min(afterVisible, base + turnBatch)
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
setState("shift", false)
}
const loadAndReveal = () => fetchOlderMessages()
const onScrollerScroll = () => {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
if (start <= turnPrefetchBuffer) {
void fetchOlderMessages({ prefetch: true })
}
backfillTurns()
return
}
if (el.scrollTop >= historyScrollThreshold) return
void fetchOlderMessages()
}
@@ -292,27 +160,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
on(
input.sessionID,
() => {
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
cancelShiftReset()
setState({ shift: false })
},
{ defer: true },
),
)
createEffect(
on(
() => [input.sessionID(), input.messagesReady()] as const,
([id, ready]) => {
if (!id || !ready) return
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
},
{ defer: true },
),
)
onCleanup(cancelShiftReset)
return {
turnStart,
setTurnStart,
renderedUserMessages,
userMessages,
shift: () => state.shift,
loadAndReveal,
onScrollerScroll,
}
@@ -333,6 +192,7 @@ export default function Page() {
const comments = useComments()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => {
@@ -737,6 +597,7 @@ export default function Page() {
let dockHeight = 0
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let revealMessage = (_id: string) => {}
let scrollMark = 0
let messageMark = 0
@@ -1403,9 +1264,8 @@ export default function Page() {
},
)
const historyWindow = createSessionHistoryWindow({
const historyLoader = createSessionHistoryLoader({
sessionID: () => params.id,
messagesReady,
loaded: () => messages().length,
visibleUserMessages,
historyMore,
@@ -1427,9 +1287,9 @@ export default function Page() {
const el = scroller
if (!el) return
if (el.scrollHeight > el.clientHeight + 1) return
if (historyWindow.turnStart() <= 0 && !historyMore()) return
if (!historyMore()) return
void historyWindow.loadAndReveal()
void historyLoader.loadAndReveal()
})
}
@@ -1439,15 +1299,14 @@ export default function Page() {
[
params.id,
messagesReady(),
historyWindow.turnStart(),
historyMore(),
historyLoading(),
autoScroll.userScrolled(),
visibleUserMessages().length,
] as const,
([id, ready, start, more, loading, scrolled]) => {
([id, ready, more, loading, scrolled]) => {
if (!id || !ready || loading || scrolled) return
if (start <= 0 && !more) return
if (!more) return
fill()
},
{ defer: true },
@@ -1749,15 +1608,14 @@ export default function Page() {
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
setPendingMessage: (value) => setUi("pendingMessage", value),
setActiveMessage,
setTurnStart: historyWindow.setTurnStart,
autoScroll,
scroller: () => scroller,
anchor,
revealMessage: (id) => revealMessage(id),
scheduleScrollState,
consumePendingMessage: layout.pendingMessage.consume,
})
@@ -1830,20 +1688,23 @@ export default function Page() {
>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
@@ -1853,8 +1714,11 @@ export default function Page() {
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
@@ -1863,14 +1727,12 @@ export default function Page() {
const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
historyShift={historyLoader.shift()}
userMessages={historyLoader.userMessages()}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/>
</Show>
</Match>