|
|
@@ -1,4 +1,4 @@
|
|
|
-import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount } from "solid-js"
|
|
|
+import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount, untrack } from "solid-js"
|
|
|
import { createMediaQuery } from "@solid-primitives/media"
|
|
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|
|
import { useLocal } from "@/context/local"
|
|
|
@@ -32,6 +32,215 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
|
|
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
|
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
|
|
|
|
|
+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
|
|
|
+ const beforeHeight = el.scrollHeight
|
|
|
+ fn()
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ const delta = el.scrollHeight - beforeHeight
|
|
|
+ if (!delta) return
|
|
|
+ el.scrollTop = beforeTop + delta
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ 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.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 layout = useLayout()
|
|
|
const local = useLocal()
|
|
|
@@ -178,7 +387,6 @@ export default function Page() {
|
|
|
return sync.session.history.loading(id)
|
|
|
})
|
|
|
|
|
|
- const emptyUserMessages: UserMessage[] = []
|
|
|
const userMessages = createMemo(
|
|
|
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
|
|
emptyUserMessages,
|
|
|
@@ -211,7 +419,6 @@ export default function Page() {
|
|
|
|
|
|
const [store, setStore] = createStore({
|
|
|
messageId: undefined as string | undefined,
|
|
|
- turnStart: 0,
|
|
|
mobileTab: "session" as "session" | "changes",
|
|
|
changes: "session" as "session" | "turn",
|
|
|
newSessionWorktree: "main",
|
|
|
@@ -220,20 +427,6 @@ export default function Page() {
|
|
|
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
|
|
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
|
|
|
|
|
- const renderedUserMessages = createMemo(
|
|
|
- () => {
|
|
|
- const msgs = visibleUserMessages()
|
|
|
- const start = store.turnStart
|
|
|
- if (start <= 0) return msgs
|
|
|
- if (start >= msgs.length) return emptyUserMessages
|
|
|
- return msgs.slice(start)
|
|
|
- },
|
|
|
- emptyUserMessages,
|
|
|
- {
|
|
|
- equals: same,
|
|
|
- },
|
|
|
- )
|
|
|
-
|
|
|
const newSessionWorktree = createMemo(() => {
|
|
|
if (store.newSessionWorktree === "create") return "create"
|
|
|
const project = sync.project
|
|
|
@@ -302,13 +495,18 @@ export default function Page() {
|
|
|
|
|
|
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
|
|
|
|
|
- createEffect(() => {
|
|
|
- sdk.directory
|
|
|
- const id = params.id
|
|
|
- if (!id) return
|
|
|
- void sync.session.sync(id)
|
|
|
- void sync.session.todo(id)
|
|
|
- })
|
|
|
+ createEffect(
|
|
|
+ on(
|
|
|
+ [() => sdk.directory, () => params.id] as const,
|
|
|
+ ([, id]) => {
|
|
|
+ if (!id) return
|
|
|
+ untrack(() => {
|
|
|
+ void sync.session.sync(id)
|
|
|
+ void sync.session.todo(id)
|
|
|
+ })
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ )
|
|
|
|
|
|
createEffect(
|
|
|
on(
|
|
|
@@ -894,88 +1092,16 @@ export default function Page() {
|
|
|
},
|
|
|
)
|
|
|
|
|
|
- const turnInit = 20
|
|
|
- const turnBatch = 20
|
|
|
- let turnHandle: number | undefined
|
|
|
- let turnIdle = false
|
|
|
-
|
|
|
- function cancelTurnBackfill() {
|
|
|
- const handle = turnHandle
|
|
|
- if (handle === undefined) return
|
|
|
- turnHandle = undefined
|
|
|
-
|
|
|
- if (turnIdle && window.cancelIdleCallback) {
|
|
|
- window.cancelIdleCallback(handle)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- clearTimeout(handle)
|
|
|
- }
|
|
|
-
|
|
|
- function scheduleTurnBackfill() {
|
|
|
- if (turnHandle !== undefined) return
|
|
|
- if (store.turnStart <= 0) return
|
|
|
-
|
|
|
- if (window.requestIdleCallback) {
|
|
|
- turnIdle = true
|
|
|
- turnHandle = window.requestIdleCallback(() => {
|
|
|
- turnHandle = undefined
|
|
|
- backfillTurns()
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- turnIdle = false
|
|
|
- turnHandle = window.setTimeout(() => {
|
|
|
- turnHandle = undefined
|
|
|
- backfillTurns()
|
|
|
- }, 0)
|
|
|
- }
|
|
|
-
|
|
|
- function backfillTurns() {
|
|
|
- const start = store.turnStart
|
|
|
- if (start <= 0) return
|
|
|
-
|
|
|
- const next = start - turnBatch
|
|
|
- const nextStart = next > 0 ? next : 0
|
|
|
-
|
|
|
- const el = scroller
|
|
|
- if (!el) {
|
|
|
- setStore("turnStart", nextStart)
|
|
|
- scheduleTurnBackfill()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const beforeTop = el.scrollTop
|
|
|
- const beforeHeight = el.scrollHeight
|
|
|
-
|
|
|
- setStore("turnStart", nextStart)
|
|
|
-
|
|
|
- requestAnimationFrame(() => {
|
|
|
- const delta = el.scrollHeight - beforeHeight
|
|
|
- if (!delta) return
|
|
|
- el.scrollTop = beforeTop + delta
|
|
|
- })
|
|
|
-
|
|
|
- scheduleTurnBackfill()
|
|
|
- }
|
|
|
-
|
|
|
- createEffect(
|
|
|
- on(
|
|
|
- () => [params.id, messagesReady()] as const,
|
|
|
- ([id, ready]) => {
|
|
|
- cancelTurnBackfill()
|
|
|
- setStore("turnStart", 0)
|
|
|
- if (!id || !ready) return
|
|
|
-
|
|
|
- const len = visibleUserMessages().length
|
|
|
- const start = len > turnInit ? len - turnInit : 0
|
|
|
- setStore("turnStart", start)
|
|
|
- scheduleTurnBackfill()
|
|
|
- },
|
|
|
- { defer: true },
|
|
|
- ),
|
|
|
- )
|
|
|
+ const historyWindow = createSessionHistoryWindow({
|
|
|
+ sessionID: () => params.id,
|
|
|
+ messagesReady,
|
|
|
+ visibleUserMessages,
|
|
|
+ historyMore,
|
|
|
+ historyLoading,
|
|
|
+ loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
|
|
+ userScrolled: autoScroll.userScrolled,
|
|
|
+ scroller: () => scroller,
|
|
|
+ })
|
|
|
|
|
|
createResizeObserver(
|
|
|
() => promptDock,
|
|
|
@@ -1002,13 +1128,12 @@ export default function Page() {
|
|
|
sessionID: () => params.id,
|
|
|
messagesReady,
|
|
|
visibleUserMessages,
|
|
|
- turnStart: () => store.turnStart,
|
|
|
+ turnStart: historyWindow.turnStart,
|
|
|
currentMessageId: () => store.messageId,
|
|
|
pendingMessage: () => ui.pendingMessage,
|
|
|
setPendingMessage: (value) => setUi("pendingMessage", value),
|
|
|
setActiveMessage,
|
|
|
- setTurnStart: (value) => setStore("turnStart", value),
|
|
|
- scheduleTurnBackfill,
|
|
|
+ setTurnStart: historyWindow.setTurnStart,
|
|
|
autoScroll,
|
|
|
scroller: () => scroller,
|
|
|
anchor,
|
|
|
@@ -1021,7 +1146,6 @@ export default function Page() {
|
|
|
})
|
|
|
|
|
|
onCleanup(() => {
|
|
|
- cancelTurnBackfill()
|
|
|
document.removeEventListener("keydown", handleKeyDown)
|
|
|
scrollSpy.destroy()
|
|
|
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
|
|
@@ -1076,6 +1200,7 @@ export default function Page() {
|
|
|
hasScrollGesture={hasScrollGesture}
|
|
|
isDesktop={isDesktop()}
|
|
|
onScrollSpyScroll={scrollSpy.onScroll}
|
|
|
+ onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
|
|
onAutoScrollInteraction={autoScroll.handleInteraction}
|
|
|
centered={centered()}
|
|
|
setContentRef={(el) => {
|
|
|
@@ -1085,17 +1210,13 @@ export default function Page() {
|
|
|
const root = scroller
|
|
|
if (root) scheduleScrollState(root)
|
|
|
}}
|
|
|
- turnStart={store.turnStart}
|
|
|
- onRenderEarlier={() => setStore("turnStart", 0)}
|
|
|
+ turnStart={historyWindow.turnStart()}
|
|
|
historyMore={historyMore()}
|
|
|
historyLoading={historyLoading()}
|
|
|
onLoadEarlier={() => {
|
|
|
- const id = params.id
|
|
|
- if (!id) return
|
|
|
- setStore("turnStart", 0)
|
|
|
- sync.session.history.loadMore(id)
|
|
|
+ void historyWindow.loadAndReveal()
|
|
|
}}
|
|
|
- renderedUserMessages={renderedUserMessages()}
|
|
|
+ renderedUserMessages={historyWindow.renderedUserMessages()}
|
|
|
anchor={anchor}
|
|
|
onRegisterMessage={scrollSpy.register}
|
|
|
onUnregisterMessage={scrollSpy.unregister}
|