Skip to content

Commit e275b09

Browse files
Apply PR #26949: perf(app): virtualize session timeline rows
2 parents 3eac4da + 240201b commit e275b09

7 files changed

Lines changed: 869 additions & 505 deletions

File tree

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
"shiki": "3.20.0",
7575
"solid-list": "0.3.0",
7676
"tailwindcss": "4.1.11",
77-
"virtua": "0.42.3",
77+
"virtua": "0.49.1",
7878
"vite": "7.1.4",
7979
"@solidjs/meta": "0.29.4",
8080
"@solidjs/router": "0.15.4",

packages/app/src/pages/session.tsx

Lines changed: 45 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ type VcsMode = "git" | "branch"
7575

7676
type SessionHistoryWindowInput = {
7777
sessionID: () => string | undefined
78-
messagesReady: () => boolean
7978
loaded: () => number
8079
visibleUserMessages: () => UserMessage[]
8180
historyMore: () => boolean
@@ -85,205 +84,78 @@ type SessionHistoryWindowInput = {
8584
scroller: () => HTMLDivElement | undefined
8685
}
8786

88-
/**
89-
* Maintains the rendered history window for a session timeline.
90-
*
91-
* It keeps initial paint bounded to recent turns, reveals cached turns in
92-
* small batches while scrolling upward, and prefetches older history near top.
93-
*/
94-
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
95-
const turnInit = 10
96-
const turnBatch = 8
97-
const turnScrollThreshold = 200
98-
const turnPrefetchBuffer = 16
99-
const prefetchCooldownMs = 400
100-
const prefetchNoGrowthLimit = 2
87+
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
88+
const historyScrollThreshold = 200
89+
let shiftFrame: number | undefined
10190

10291
const [state, setState] = createStore({
103-
turnID: undefined as string | undefined,
104-
turnStart: 0,
105-
prefetchUntil: 0,
106-
prefetchNoGrowth: 0,
92+
shift: false,
10793
})
10894

109-
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
110-
111-
const turnStart = createMemo(() => {
112-
const id = input.sessionID()
113-
const len = input.visibleUserMessages().length
114-
if (!id || len <= 0) return 0
115-
if (state.turnID !== id) return initialTurnStart(len)
116-
if (state.turnStart <= 0) return 0
117-
if (state.turnStart >= len) return initialTurnStart(len)
118-
return state.turnStart
119-
})
120-
121-
const setTurnStart = (start: number) => {
122-
const id = input.sessionID()
123-
const next = start > 0 ? start : 0
124-
if (!id) {
125-
setState({ turnID: undefined, turnStart: next })
126-
return
127-
}
128-
setState({ turnID: id, turnStart: next })
129-
}
130-
131-
const renderedUserMessages = createMemo(
132-
() => {
133-
const msgs = input.visibleUserMessages()
134-
const start = turnStart()
135-
if (start <= 0) return msgs
136-
return msgs.slice(start)
137-
},
95+
const userMessages = createMemo(
96+
() => input.visibleUserMessages(),
13897
emptyUserMessages,
13998
{
14099
equals: same,
141100
},
142101
)
143102

144-
const preserveScroll = (fn: () => void) => {
145-
const el = input.scroller()
146-
if (!el) {
147-
fn()
148-
return
149-
}
150-
const beforeTop = el.scrollTop
151-
const beforeHeight = el.scrollHeight
152-
fn()
153-
requestAnimationFrame(() => {
154-
const delta = el.scrollHeight - beforeHeight
155-
if (!delta) return
156-
el.scrollTop = beforeTop + delta
157-
})
158-
}
159-
160-
const backfillTurns = () => {
161-
const start = turnStart()
162-
if (start <= 0) return
163-
164-
const next = start - turnBatch
165-
const nextStart = next > 0 ? next : 0
166-
167-
preserveScroll(() => setTurnStart(nextStart))
103+
const cancelShiftReset = () => {
104+
if (shiftFrame === undefined) return
105+
cancelAnimationFrame(shiftFrame)
106+
shiftFrame = undefined
168107
}
169108

170-
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
171-
const loadAndReveal = async () => {
172-
const id = input.sessionID()
173-
if (!id) return
174-
175-
const start = turnStart()
176-
const beforeVisible = input.visibleUserMessages().length
177-
let loaded = input.loaded()
178-
179-
if (start > 0) setTurnStart(0)
180-
181-
if (!input.historyMore() || input.historyLoading()) return
182-
183-
let afterVisible = beforeVisible
184-
let added = 0
185-
186-
while (true) {
187-
await input.loadMore(id)
188-
if (input.sessionID() !== id) return
189-
190-
afterVisible = input.visibleUserMessages().length
191-
const nextLoaded = input.loaded()
192-
const raw = nextLoaded - loaded
193-
added += raw
194-
loaded = nextLoaded
195-
196-
if (afterVisible > beforeVisible) break
197-
if (raw <= 0) break
198-
if (!input.historyMore()) break
199-
}
200-
201-
if (added <= 0) return
202-
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
203-
204-
const growth = afterVisible - beforeVisible
205-
if (growth <= 0) return
206-
if (turnStart() !== 0) return
207-
208-
const target = Math.min(afterVisible, beforeVisible + turnBatch)
209-
setTurnStart(Math.max(0, afterVisible - target))
109+
const scheduleShiftReset = () => {
110+
cancelShiftReset()
111+
shiftFrame = requestAnimationFrame(() => {
112+
shiftFrame = undefined
113+
setState("shift", false)
114+
})
210115
}
211116

212-
/** Scroll/prefetch path: fetch older history from server. */
213-
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
117+
const fetchOlderMessages = async () => {
214118
const id = input.sessionID()
215119
if (!id) return
216120
if (!input.historyMore() || input.historyLoading()) return
217121

218-
if (opts?.prefetch) {
219-
const now = Date.now()
220-
if (state.prefetchUntil > now) return
221-
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
222-
setState("prefetchUntil", now + prefetchCooldownMs)
223-
}
224-
225-
const start = turnStart()
122+
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
226123
const beforeVisible = input.visibleUserMessages().length
227-
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
228124
let loaded = input.loaded()
229-
let added = 0
230125
let growth = 0
231126

127+
cancelShiftReset()
128+
setState("shift", true)
129+
232130
while (true) {
233131
await input.loadMore(id)
234132
if (input.sessionID() !== id) return
235133

236134
const nextLoaded = input.loaded()
237135
const raw = nextLoaded - loaded
238-
added += raw
239136
loaded = nextLoaded
240137
growth = input.visibleUserMessages().length - beforeVisible
241138

242139
if (growth > 0) break
243140
if (raw <= 0) break
244-
if (opts?.prefetch) break
245141
if (!input.historyMore()) break
246142
}
247143

248-
const afterVisible = input.visibleUserMessages().length
249-
250-
if (opts?.prefetch) {
251-
setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1)
252-
} else if (added > 0 && state.prefetchNoGrowth) {
253-
setState("prefetchNoGrowth", 0)
254-
}
255-
256-
if (added <= 0) return
257-
if (growth <= 0) return
258-
259-
if (opts?.prefetch) {
260-
const current = turnStart()
261-
preserveScroll(() => setTurnStart(current + growth))
144+
if (growth > 0) {
145+
scheduleShiftReset()
262146
return
263147
}
264148

265-
if (turnStart() !== start) return
266-
267-
const currentRendered = renderedUserMessages().length
268-
const base = Math.max(beforeRendered, currentRendered)
269-
const target = Math.min(afterVisible, base + turnBatch)
270-
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
149+
setState("shift", false)
271150
}
272151

152+
const loadAndReveal = () => fetchOlderMessages()
153+
273154
const onScrollerScroll = () => {
274155
if (!input.userScrolled()) return
275156
const el = input.scroller()
276157
if (!el) return
277-
if (el.scrollTop >= turnScrollThreshold) return
278-
279-
const start = turnStart()
280-
if (start > 0) {
281-
if (start <= turnPrefetchBuffer) {
282-
void fetchOlderMessages({ prefetch: true })
283-
}
284-
backfillTurns()
285-
return
286-
}
158+
if (el.scrollTop >= historyScrollThreshold) return
287159

288160
void fetchOlderMessages()
289161
}
@@ -292,27 +164,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
292164
on(
293165
input.sessionID,
294166
() => {
295-
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
167+
cancelShiftReset()
168+
setState({ shift: false })
296169
},
297170
{ defer: true },
298171
),
299172
)
300173

301-
createEffect(
302-
on(
303-
() => [input.sessionID(), input.messagesReady()] as const,
304-
([id, ready]) => {
305-
if (!id || !ready) return
306-
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
307-
},
308-
{ defer: true },
309-
),
310-
)
174+
onCleanup(cancelShiftReset)
311175

312176
return {
313-
turnStart,
314-
setTurnStart,
315-
renderedUserMessages,
177+
userMessages,
178+
shift: () => state.shift,
316179
loadAndReveal,
317180
onScrollerScroll,
318181
}
@@ -737,6 +600,7 @@ export default function Page() {
737600
let dockHeight = 0
738601
let scroller: HTMLDivElement | undefined
739602
let content: HTMLDivElement | undefined
603+
let revealMessage = (_id: string) => {}
740604
let scrollMark = 0
741605
let messageMark = 0
742606

@@ -1403,9 +1267,8 @@ export default function Page() {
14031267
},
14041268
)
14051269

1406-
const historyWindow = createSessionHistoryWindow({
1270+
const historyLoader = createSessionHistoryLoader({
14071271
sessionID: () => params.id,
1408-
messagesReady,
14091272
loaded: () => messages().length,
14101273
visibleUserMessages,
14111274
historyMore,
@@ -1427,9 +1290,9 @@ export default function Page() {
14271290
const el = scroller
14281291
if (!el) return
14291292
if (el.scrollHeight > el.clientHeight + 1) return
1430-
if (historyWindow.turnStart() <= 0 && !historyMore()) return
1293+
if (!historyMore()) return
14311294

1432-
void historyWindow.loadAndReveal()
1295+
void historyLoader.loadAndReveal()
14331296
})
14341297
}
14351298

@@ -1439,15 +1302,14 @@ export default function Page() {
14391302
[
14401303
params.id,
14411304
messagesReady(),
1442-
historyWindow.turnStart(),
14431305
historyMore(),
14441306
historyLoading(),
14451307
autoScroll.userScrolled(),
14461308
visibleUserMessages().length,
14471309
] as const,
1448-
([id, ready, start, more, loading, scrolled]) => {
1310+
([id, ready, more, loading, scrolled]) => {
14491311
if (!id || !ready || loading || scrolled) return
1450-
if (start <= 0 && !more) return
1312+
if (!more) return
14511313
fill()
14521314
},
14531315
{ defer: true },
@@ -1754,15 +1616,14 @@ export default function Page() {
17541616
historyMore,
17551617
historyLoading,
17561618
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
1757-
turnStart: historyWindow.turnStart,
17581619
currentMessageId: () => store.messageId,
17591620
pendingMessage: () => ui.pendingMessage,
17601621
setPendingMessage: (value) => setUi("pendingMessage", value),
17611622
setActiveMessage,
1762-
setTurnStart: historyWindow.setTurnStart,
17631623
autoScroll,
17641624
scroller: () => scroller,
17651625
anchor,
1626+
revealMessage: (id) => revealMessage(id),
17661627
scheduleScrollState,
17671628
consumePendingMessage: layout.pendingMessage.consume,
17681629
})
@@ -1858,7 +1719,7 @@ export default function Page() {
18581719
onMarkScrollGesture={markScrollGesture}
18591720
hasScrollGesture={hasScrollGesture}
18601721
onUserScroll={markUserScroll}
1861-
onTurnBackfillScroll={historyWindow.onScrollerScroll}
1722+
onHistoryScroll={historyLoader.onScrollerScroll}
18621723
onAutoScrollInteraction={autoScroll.handleInteraction}
18631724
centered={centered()}
18641725
setContentRef={(el) => {
@@ -1868,14 +1729,12 @@ export default function Page() {
18681729
const root = scroller
18691730
if (root) scheduleScrollState(root)
18701731
}}
1871-
turnStart={historyWindow.turnStart()}
1872-
historyMore={historyMore()}
1873-
historyLoading={historyLoading()}
1874-
onLoadEarlier={() => {
1875-
void historyWindow.loadAndReveal()
1876-
}}
1877-
renderedUserMessages={historyWindow.renderedUserMessages()}
1732+
historyShift={historyLoader.shift()}
1733+
userMessages={historyLoader.userMessages()}
18781734
anchor={anchor}
1735+
setRevealMessage={(fn) => {
1736+
revealMessage = fn
1737+
}}
18791738
/>
18801739
</Show>
18811740
</Match>

0 commit comments

Comments
 (0)