|
|
@@ -41,216 +41,12 @@ import { createScrollSpy } from "@/pages/session/scroll-spy"
|
|
|
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
|
|
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
|
|
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
|
|
+import { createSessionHistoryWindow, emptyUserMessages } from "@/pages/session/history-window"
|
|
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
|
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
|
|
import { same } from "@/utils/same"
|
|
|
import { formatServerError } from "@/utils/server-errors"
|
|
|
|
|
|
-const emptyUserMessages: UserMessage[] = []
|
|
|
-
|
|
|
-type SessionHistoryWindowInput = {
|
|
|
- sessionID: () => string | undefined
|
|
|
- messagesReady: () => boolean
|
|
|
- visibleUserMessages: () => UserMessage[]
|
|
|
- historyMore: () => boolean
|
|
|
- historyLoading: () => boolean
|
|
|
- loadMore: (sessionID: string) => Promise<void>
|
|
|
- userScrolled: () => boolean
|
|
|
- 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
|
|
|
-
|
|
|
- const [state, setState] = createStore({
|
|
|
- turnID: undefined as string | undefined,
|
|
|
- turnStart: 0,
|
|
|
- prefetchUntil: 0,
|
|
|
- prefetchNoGrowth: 0,
|
|
|
- })
|
|
|
-
|
|
|
- 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 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 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
|
|
|
- fn()
|
|
|
- void el.scrollHeight
|
|
|
- el.scrollTop = beforeTop
|
|
|
- }
|
|
|
-
|
|
|
- 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
|
|
|
-
|
|
|
- if (start > 0) setTurnStart(0)
|
|
|
-
|
|
|
- if (!input.historyMore() || input.historyLoading()) return
|
|
|
-
|
|
|
- await input.loadMore(id)
|
|
|
- if (input.sessionID() !== id) return
|
|
|
-
|
|
|
- const afterVisible = input.visibleUserMessages().length
|
|
|
- const growth = afterVisible - beforeVisible
|
|
|
- if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
|
|
|
- if (growth <= 0) return
|
|
|
- if (turnStart() !== 0) return
|
|
|
-
|
|
|
- const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
|
|
|
- const nextStart = Math.max(0, afterVisible - target)
|
|
|
- preserveScroll(() => setTurnStart(nextStart))
|
|
|
- }
|
|
|
-
|
|
|
- /** Scroll/prefetch path: fetch older history from server. */
|
|
|
- const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
|
|
|
- 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()
|
|
|
- const beforeVisible = input.visibleUserMessages().length
|
|
|
- const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
|
|
|
-
|
|
|
- await input.loadMore(id)
|
|
|
- if (input.sessionID() !== id) return
|
|
|
-
|
|
|
- const afterVisible = input.visibleUserMessages().length
|
|
|
- const growth = afterVisible - beforeVisible
|
|
|
-
|
|
|
- if (opts?.prefetch) {
|
|
|
- setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
|
|
|
- } else if (growth > 0 && state.prefetchNoGrowth) {
|
|
|
- setState("prefetchNoGrowth", 0)
|
|
|
- }
|
|
|
-
|
|
|
- if (growth <= 0) return
|
|
|
- if (turnStart() !== start) return
|
|
|
-
|
|
|
- const reveal = !opts?.prefetch
|
|
|
- const currentRendered = renderedUserMessages().length
|
|
|
- const base = Math.max(beforeRendered, currentRendered)
|
|
|
- const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
|
|
|
- const nextStart = Math.max(0, afterVisible - target)
|
|
|
- preserveScroll(() => setTurnStart(nextStart))
|
|
|
- }
|
|
|
-
|
|
|
- const onScrollerScroll = () => {
|
|
|
- if (!input.userScrolled()) return
|
|
|
- const el = input.scroller()
|
|
|
- if (!el) return
|
|
|
- if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
|
|
|
-
|
|
|
- const start = turnStart()
|
|
|
- if (start > 0) {
|
|
|
- if (start <= turnPrefetchBuffer) {
|
|
|
- void fetchOlderMessages({ prefetch: true })
|
|
|
- }
|
|
|
- backfillTurns()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- void fetchOlderMessages()
|
|
|
- }
|
|
|
-
|
|
|
- createEffect(
|
|
|
- on(
|
|
|
- input.sessionID,
|
|
|
- () => {
|
|
|
- setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
|
|
|
- },
|
|
|
- { defer: true },
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- createEffect(
|
|
|
- on(
|
|
|
- () => [input.sessionID(), input.messagesReady()] as const,
|
|
|
- ([id, ready]) => {
|
|
|
- if (!id || !ready) return
|
|
|
- setTurnStart(initialTurnStart(input.visibleUserMessages().length))
|
|
|
- },
|
|
|
- { defer: true },
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- return {
|
|
|
- turnStart,
|
|
|
- setTurnStart,
|
|
|
- renderedUserMessages,
|
|
|
- loadAndReveal,
|
|
|
- onScrollerScroll,
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
export default function Page() {
|
|
|
const globalSync = useGlobalSync()
|
|
|
const layout = useLayout()
|
|
|
@@ -1090,6 +886,7 @@ export default function Page() {
|
|
|
|
|
|
let scrollStateFrame: number | undefined
|
|
|
let scrollStateTarget: HTMLDivElement | undefined
|
|
|
+ let historyFillFrame: number | undefined
|
|
|
const scrollSpy = createScrollSpy({
|
|
|
onActive: (id) => {
|
|
|
if (id === store.messageId) return
|
|
|
@@ -1159,7 +956,9 @@ export default function Page() {
|
|
|
scroller = el
|
|
|
autoScroll.scrollRef(el)
|
|
|
scrollSpy.setContainer(el)
|
|
|
- if (el) scheduleScrollState(el)
|
|
|
+ if (!el) return
|
|
|
+ scheduleScrollState(el)
|
|
|
+ scheduleHistoryFill()
|
|
|
}
|
|
|
|
|
|
createResizeObserver(
|
|
|
@@ -1168,6 +967,7 @@ export default function Page() {
|
|
|
const el = scroller
|
|
|
if (el) scheduleScrollState(el)
|
|
|
scrollSpy.markDirty()
|
|
|
+ scheduleHistoryFill()
|
|
|
},
|
|
|
)
|
|
|
|
|
|
@@ -1182,6 +982,45 @@ export default function Page() {
|
|
|
scroller: () => scroller,
|
|
|
})
|
|
|
|
|
|
+ const scheduleHistoryFill = () => {
|
|
|
+ if (historyFillFrame !== undefined) return
|
|
|
+
|
|
|
+ historyFillFrame = requestAnimationFrame(() => {
|
|
|
+ historyFillFrame = undefined
|
|
|
+
|
|
|
+ if (!params.id || !messagesReady()) return
|
|
|
+ if (autoScroll.userScrolled() || historyLoading()) return
|
|
|
+
|
|
|
+ const el = scroller
|
|
|
+ if (!el) return
|
|
|
+ if (el.scrollHeight > el.clientHeight + 1) return
|
|
|
+ if (historyWindow.turnStart() <= 0 && !historyMore()) return
|
|
|
+
|
|
|
+ void historyWindow.loadAndReveal()
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ createEffect(
|
|
|
+ on(
|
|
|
+ () =>
|
|
|
+ [
|
|
|
+ params.id,
|
|
|
+ messagesReady(),
|
|
|
+ historyWindow.turnStart(),
|
|
|
+ historyMore(),
|
|
|
+ historyLoading(),
|
|
|
+ autoScroll.userScrolled(),
|
|
|
+ visibleUserMessages().length,
|
|
|
+ ] as const,
|
|
|
+ ([id, ready, start, more, loading, scrolled]) => {
|
|
|
+ if (!id || !ready || loading || scrolled) return
|
|
|
+ if (start <= 0 && !more) return
|
|
|
+ scheduleHistoryFill()
|
|
|
+ },
|
|
|
+ { defer: true },
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
createResizeObserver(
|
|
|
() => promptDock,
|
|
|
({ height }) => {
|
|
|
@@ -1199,6 +1038,7 @@ export default function Page() {
|
|
|
|
|
|
if (el) scheduleScrollState(el)
|
|
|
scrollSpy.markDirty()
|
|
|
+ scheduleHistoryFill()
|
|
|
},
|
|
|
)
|
|
|
|
|
|
@@ -1228,6 +1068,7 @@ export default function Page() {
|
|
|
document.removeEventListener("keydown", handleKeyDown)
|
|
|
scrollSpy.destroy()
|
|
|
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
|
|
+ if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame)
|
|
|
})
|
|
|
|
|
|
return (
|