Skip to content

Commit c797b60

Browse files
committed
fix(app): messages not loading reliably
1 parent a139e92 commit c797b60

3 files changed

Lines changed: 355 additions & 206 deletions

File tree

packages/app/src/pages/session.tsx

Lines changed: 47 additions & 206 deletions
Original file line numberDiff line numberDiff line change
@@ -41,216 +41,12 @@ import { createScrollSpy } from "@/pages/session/scroll-spy"
4141
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
4242
import { SessionSidePanel } from "@/pages/session/session-side-panel"
4343
import { TerminalPanel } from "@/pages/session/terminal-panel"
44+
import { createSessionHistoryWindow, emptyUserMessages } from "@/pages/session/history-window"
4445
import { useSessionCommands } from "@/pages/session/use-session-commands"
4546
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
4647
import { same } from "@/utils/same"
4748
import { formatServerError } from "@/utils/server-errors"
4849

49-
const emptyUserMessages: UserMessage[] = []
50-
51-
type SessionHistoryWindowInput = {
52-
sessionID: () => string | undefined
53-
messagesReady: () => boolean
54-
visibleUserMessages: () => UserMessage[]
55-
historyMore: () => boolean
56-
historyLoading: () => boolean
57-
loadMore: (sessionID: string) => Promise<void>
58-
userScrolled: () => boolean
59-
scroller: () => HTMLDivElement | undefined
60-
}
61-
62-
/**
63-
* Maintains the rendered history window for a session timeline.
64-
*
65-
* It keeps initial paint bounded to recent turns, reveals cached turns in
66-
* small batches while scrolling upward, and prefetches older history near top.
67-
*/
68-
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
69-
const turnInit = 10
70-
const turnBatch = 8
71-
const turnScrollThreshold = 200
72-
const turnPrefetchBuffer = 16
73-
const prefetchCooldownMs = 400
74-
const prefetchNoGrowthLimit = 2
75-
76-
const [state, setState] = createStore({
77-
turnID: undefined as string | undefined,
78-
turnStart: 0,
79-
prefetchUntil: 0,
80-
prefetchNoGrowth: 0,
81-
})
82-
83-
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
84-
85-
const turnStart = createMemo(() => {
86-
const id = input.sessionID()
87-
const len = input.visibleUserMessages().length
88-
if (!id || len <= 0) return 0
89-
if (state.turnID !== id) return initialTurnStart(len)
90-
if (state.turnStart <= 0) return 0
91-
if (state.turnStart >= len) return initialTurnStart(len)
92-
return state.turnStart
93-
})
94-
95-
const setTurnStart = (start: number) => {
96-
const id = input.sessionID()
97-
const next = start > 0 ? start : 0
98-
if (!id) {
99-
setState({ turnID: undefined, turnStart: next })
100-
return
101-
}
102-
setState({ turnID: id, turnStart: next })
103-
}
104-
105-
const renderedUserMessages = createMemo(
106-
() => {
107-
const msgs = input.visibleUserMessages()
108-
const start = turnStart()
109-
if (start <= 0) return msgs
110-
return msgs.slice(start)
111-
},
112-
emptyUserMessages,
113-
{
114-
equals: same,
115-
},
116-
)
117-
118-
const preserveScroll = (fn: () => void) => {
119-
const el = input.scroller()
120-
if (!el) {
121-
fn()
122-
return
123-
}
124-
const beforeTop = el.scrollTop
125-
fn()
126-
void el.scrollHeight
127-
el.scrollTop = beforeTop
128-
}
129-
130-
const backfillTurns = () => {
131-
const start = turnStart()
132-
if (start <= 0) return
133-
134-
const next = start - turnBatch
135-
const nextStart = next > 0 ? next : 0
136-
137-
preserveScroll(() => setTurnStart(nextStart))
138-
}
139-
140-
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
141-
const loadAndReveal = async () => {
142-
const id = input.sessionID()
143-
if (!id) return
144-
145-
const start = turnStart()
146-
const beforeVisible = input.visibleUserMessages().length
147-
148-
if (start > 0) setTurnStart(0)
149-
150-
if (!input.historyMore() || input.historyLoading()) return
151-
152-
await input.loadMore(id)
153-
if (input.sessionID() !== id) return
154-
155-
const afterVisible = input.visibleUserMessages().length
156-
const growth = afterVisible - beforeVisible
157-
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
158-
if (growth <= 0) return
159-
if (turnStart() !== 0) return
160-
161-
const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
162-
const nextStart = Math.max(0, afterVisible - target)
163-
preserveScroll(() => setTurnStart(nextStart))
164-
}
165-
166-
/** Scroll/prefetch path: fetch older history from server. */
167-
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
168-
const id = input.sessionID()
169-
if (!id) return
170-
if (!input.historyMore() || input.historyLoading()) return
171-
172-
if (opts?.prefetch) {
173-
const now = Date.now()
174-
if (state.prefetchUntil > now) return
175-
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
176-
setState("prefetchUntil", now + prefetchCooldownMs)
177-
}
178-
179-
const start = turnStart()
180-
const beforeVisible = input.visibleUserMessages().length
181-
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
182-
183-
await input.loadMore(id)
184-
if (input.sessionID() !== id) return
185-
186-
const afterVisible = input.visibleUserMessages().length
187-
const growth = afterVisible - beforeVisible
188-
189-
if (opts?.prefetch) {
190-
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
191-
} else if (growth > 0 && state.prefetchNoGrowth) {
192-
setState("prefetchNoGrowth", 0)
193-
}
194-
195-
if (growth <= 0) return
196-
if (turnStart() !== start) return
197-
198-
const reveal = !opts?.prefetch
199-
const currentRendered = renderedUserMessages().length
200-
const base = Math.max(beforeRendered, currentRendered)
201-
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
202-
const nextStart = Math.max(0, afterVisible - target)
203-
preserveScroll(() => setTurnStart(nextStart))
204-
}
205-
206-
const onScrollerScroll = () => {
207-
if (!input.userScrolled()) return
208-
const el = input.scroller()
209-
if (!el) return
210-
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
211-
212-
const start = turnStart()
213-
if (start > 0) {
214-
if (start <= turnPrefetchBuffer) {
215-
void fetchOlderMessages({ prefetch: true })
216-
}
217-
backfillTurns()
218-
return
219-
}
220-
221-
void fetchOlderMessages()
222-
}
223-
224-
createEffect(
225-
on(
226-
input.sessionID,
227-
() => {
228-
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
229-
},
230-
{ defer: true },
231-
),
232-
)
233-
234-
createEffect(
235-
on(
236-
() => [input.sessionID(), input.messagesReady()] as const,
237-
([id, ready]) => {
238-
if (!id || !ready) return
239-
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
240-
},
241-
{ defer: true },
242-
),
243-
)
244-
245-
return {
246-
turnStart,
247-
setTurnStart,
248-
renderedUserMessages,
249-
loadAndReveal,
250-
onScrollerScroll,
251-
}
252-
}
253-
25450
export default function Page() {
25551
const globalSync = useGlobalSync()
25652
const layout = useLayout()
@@ -1090,6 +886,7 @@ export default function Page() {
1090886

1091887
let scrollStateFrame: number | undefined
1092888
let scrollStateTarget: HTMLDivElement | undefined
889+
let historyFillFrame: number | undefined
1093890
const scrollSpy = createScrollSpy({
1094891
onActive: (id) => {
1095892
if (id === store.messageId) return
@@ -1159,7 +956,9 @@ export default function Page() {
1159956
scroller = el
1160957
autoScroll.scrollRef(el)
1161958
scrollSpy.setContainer(el)
1162-
if (el) scheduleScrollState(el)
959+
if (!el) return
960+
scheduleScrollState(el)
961+
scheduleHistoryFill()
1163962
}
1164963

1165964
createResizeObserver(
@@ -1168,6 +967,7 @@ export default function Page() {
1168967
const el = scroller
1169968
if (el) scheduleScrollState(el)
1170969
scrollSpy.markDirty()
970+
scheduleHistoryFill()
1171971
},
1172972
)
1173973

@@ -1182,6 +982,45 @@ export default function Page() {
1182982
scroller: () => scroller,
1183983
})
1184984

985+
const scheduleHistoryFill = () => {
986+
if (historyFillFrame !== undefined) return
987+
988+
historyFillFrame = requestAnimationFrame(() => {
989+
historyFillFrame = undefined
990+
991+
if (!params.id || !messagesReady()) return
992+
if (autoScroll.userScrolled() || historyLoading()) return
993+
994+
const el = scroller
995+
if (!el) return
996+
if (el.scrollHeight > el.clientHeight + 1) return
997+
if (historyWindow.turnStart() <= 0 && !historyMore()) return
998+
999+
void historyWindow.loadAndReveal()
1000+
})
1001+
}
1002+
1003+
createEffect(
1004+
on(
1005+
() =>
1006+
[
1007+
params.id,
1008+
messagesReady(),
1009+
historyWindow.turnStart(),
1010+
historyMore(),
1011+
historyLoading(),
1012+
autoScroll.userScrolled(),
1013+
visibleUserMessages().length,
1014+
] as const,
1015+
([id, ready, start, more, loading, scrolled]) => {
1016+
if (!id || !ready || loading || scrolled) return
1017+
if (start <= 0 && !more) return
1018+
scheduleHistoryFill()
1019+
},
1020+
{ defer: true },
1021+
),
1022+
)
1023+
11851024
createResizeObserver(
11861025
() => promptDock,
11871026
({ height }) => {
@@ -1199,6 +1038,7 @@ export default function Page() {
11991038

12001039
if (el) scheduleScrollState(el)
12011040
scrollSpy.markDirty()
1041+
scheduleHistoryFill()
12021042
},
12031043
)
12041044

@@ -1228,6 +1068,7 @@ export default function Page() {
12281068
document.removeEventListener("keydown", handleKeyDown)
12291069
scrollSpy.destroy()
12301070
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
1071+
if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame)
12311072
})
12321073

12331074
return (
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { historyLoadMode, historyRevealTop } from "./history-window"
3+
4+
describe("historyLoadMode", () => {
5+
test("reveals cached turns before fetching", () => {
6+
expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal")
7+
})
8+
9+
test("fetches older history when cache is already revealed", () => {
10+
expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch")
11+
})
12+
13+
test("does nothing while history is unavailable or loading", () => {
14+
expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop")
15+
expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop")
16+
})
17+
})
18+
19+
describe("historyRevealTop", () => {
20+
test("pins the viewport to the top when older turns were revealed there", () => {
21+
expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
22+
-1400,
23+
)
24+
})
25+
26+
test("keeps the latest turns pinned when the viewport was underfilled", () => {
27+
expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0)
28+
})
29+
30+
test("keeps the current anchor when the user was not at the top", () => {
31+
expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
32+
-200,
33+
)
34+
})
35+
})

0 commit comments

Comments
 (0)