diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 60888b1a6fd..ed54751c3ca 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -43,12 +43,11 @@ type OptimisticRemoveInput = { export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { const messages = draft.message[input.sessionID] - if (!messages) { - draft.message[input.sessionID] = [input.message] - } if (messages) { const result = Binary.search(messages, input.message.id, (m) => m.id) messages.splice(result.index, 0, input.message) + } else { + draft.message[input.sessionID] = [input.message] } draft.part[input.message.id] = sortParts(input.parts) } @@ -105,7 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messagePageSize = 400 + const messagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -122,20 +121,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return undefined } - const limitFor = (count: number) => { - if (count <= messagePageSize) return messagePageSize - return Math.ceil(count / messagePageSize) * messagePageSize - } - const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => { const messages = await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }), ) const items = (messages.data ?? []).filter((x) => !!x?.info?.id) - const session = items - .map((x) => x.info) - .filter((m) => !!m?.id) - .sort((a, b) => cmp(a.id, b.id)) + const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id)) const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) return { session, @@ -159,8 +150,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .then((next) => { batch(() => { input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" })) - for (const message of next.part) { - input.setStore("part", message.id, reconcile(message.part, { key: "id" })) + for (const p of next.part) { + input.setStore("part", p.id, p.part) } setMeta("limit", key, input.limit) setMeta("complete", key, next.complete) @@ -229,17 +220,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const client = sdk.client const [store, setStore] = globalSync.child(directory) const key = keyFor(directory, sessionID) - const hasSession = (() => { - const match = Binary.search(store.session, sessionID, (s) => s.id) - return match.found - })() + const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found - const hasMessages = store.message[sessionID] !== undefined - const hydrated = meta.limit[key] !== undefined - if (hasSession && hasMessages && hydrated) return - - const count = store.message[sessionID]?.length ?? 0 - const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count) + const limit = meta.limit[key] ?? messagePageSize const sessionReq = hasSession ? Promise.resolve() @@ -259,16 +242,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) }) - const messagesReq = - hasMessages && hydrated - ? Promise.resolve() - : loadMessages({ - directory, - client, - setStore, - sessionID, - limit, - }) + const messagesReq = loadMessages({ + directory, + client, + setStore, + sessionID, + limit, + }) return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) }, @@ -290,14 +270,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const client = sdk.client const [store, setStore] = globalSync.child(directory) const existing = store.todo[sessionID] + const cached = globalSync.data.session_todo[sessionID] if (existing !== undefined) { - if (globalSync.data.session_todo[sessionID] === undefined) { + if (cached === undefined) { globalSync.todo.set(sessionID, existing) } return } - const cached = globalSync.data.session_todo[sessionID] if (cached !== undefined) { setStore("todo", sessionID, reconcile(cached, { key: "id" })) } @@ -324,11 +304,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(sdk.directory, sessionID) return meta.loading[key] ?? false }, - async loadMore(sessionID: string, count = messagePageSize) { + async loadMore(sessionID: string, count?: number) { const directory = sdk.directory const client = sdk.client const [, setStore] = globalSync.child(directory) const key = keyFor(directory, sessionID) + const step = count ?? messagePageSize if (meta.loading[key]) return if (meta.complete[key]) return @@ -338,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ client, setStore, sessionID, - limit: currentLimit + count, + limit: currentLimit + step, }) }, }, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5ef68cc5c57..aadf18f13e1 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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 + 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} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index d2de720a312..7c711989ea1 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,4 +1,4 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "solid-js" +import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, type JSX } from "solid-js" import { createStore, produce } from "solid-js/store" import { useNavigate, useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" @@ -81,6 +81,103 @@ const markBoundaryGesture = (input: { } } +type StageConfig = { + init: number + batch: number +} + +type TimelineStageInput = { + sessionKey: () => string + turnStart: () => number + messages: () => UserMessage[] + config: StageConfig +} + +/** + * Defer-mounts small timeline windows so revealing older turns does not + * block first paint with a large DOM mount. + * + * Once staging completes for a session it never re-stages — backfill and + * new messages render immediately. + */ +function createTimelineStaging(input: TimelineStageInput) { + const [state, setState] = createStore({ + activeSession: "", + completedSession: "", + count: 0, + }) + + const stagedCount = createMemo(() => { + const total = input.messages().length + if (input.turnStart() <= 0) return total + if (state.completedSession === input.sessionKey()) return total + const init = Math.min(total, input.config.init) + if (state.count <= init) return init + if (state.count >= total) return total + return state.count + }) + + const stagedUserMessages = createMemo(() => { + const list = input.messages() + const count = stagedCount() + if (count >= list.length) return list + return list.slice(Math.max(0, list.length - count)) + }) + + let frame: number | undefined + const cancel = () => { + if (frame === undefined) return + cancelAnimationFrame(frame) + frame = undefined + } + + createEffect( + on( + () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, + ([sessionKey, isWindowed, total]) => { + cancel() + const shouldStage = + isWindowed && + total > input.config.init && + state.completedSession !== sessionKey && + state.activeSession !== sessionKey + if (!shouldStage) { + setState({ activeSession: "", count: total }) + return + } + + let count = Math.min(total, input.config.init) + setState({ activeSession: sessionKey, count }) + + const step = () => { + if (input.sessionKey() !== sessionKey) { + frame = undefined + return + } + const currentTotal = input.messages().length + count = Math.min(currentTotal, count + input.config.batch) + startTransition(() => setState("count", count)) + if (count >= currentTotal) { + setState({ completedSession: sessionKey, activeSession: "" }) + frame = undefined + return + } + frame = requestAnimationFrame(step) + } + frame = requestAnimationFrame(step) + }, + ), + ) + + const isStaging = createMemo(() => { + const key = input.sessionKey() + return state.activeSession === key && state.completedSession !== key + }) + + onCleanup(cancel) + return { messages: stagedUserMessages, isStaging } +} + export function MessageTimeline(props: { mobileChanges: boolean mobileFallback: JSX.Element @@ -93,11 +190,11 @@ export function MessageTimeline(props: { hasScrollGesture: () => boolean isDesktop: boolean onScrollSpyScroll: () => void + onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number - onRenderEarlier: () => void historyMore: boolean historyLoading: boolean onLoadEarlier: () => void @@ -126,6 +223,13 @@ export function MessageTimeline(props: { const titleValue = createMemo(() => info()?.title) const parentID = createMemo(() => info()?.parentID) const showHeader = createMemo(() => !!(titleValue() || parentID())) + const stageCfg = { init: 1, batch: 3 } + const staging = createTimelineStaging({ + sessionKey, + turnStart: () => props.turnStart, + messages: () => props.renderedUserMessages, + config: stageCfg, + }) const [title, setTitle] = createStore({ draft: "", @@ -342,8 +446,10 @@ export function MessageTimeline(props: {
-
- - + 0 || props.historyMore}>
- + {(message) => { const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? [])) + const commentCount = createMemo(() => comments().length) return (
- 0}> + 0}>
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index b704e460bc0..23571458874 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -19,7 +19,6 @@ export const useSessionHashScroll = (input: { setPendingMessage: (value: string | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void setTurnStart: (value: number) => void - scheduleTurnBackfill: () => void autoScroll: { pause: () => void; forceScrollToBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string @@ -58,7 +57,6 @@ export const useSessionHashScroll = (input: { const index = messageIndex().get(message.id) ?? -1 if (index !== -1 && index < input.turnStart()) { input.setTurnStart(index) - input.scheduleTurnBackfill() requestAnimationFrame(() => { const el = document.getElementById(input.anchor(message.id))