From de0c164a07872a6a937f2522cbcf518271f89ba5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Feb 2026 12:11:53 -0500 Subject: [PATCH 01/10] perf(session): speed up session switching with warm cache and staged rendering --- packages/app/src/context/sync.tsx | 75 ++++++++--- packages/app/src/pages/session.tsx | 124 +++++++++--------- .../src/pages/session/message-timeline.tsx | 97 +++++++++++++- .../pages/session/use-session-hash-scroll.ts | 2 - packages/opencode/src/session/index.ts | 10 +- packages/opencode/src/session/message-v2.ts | 97 +++++++++++--- 6 files changed, 292 insertions(+), 113 deletions(-) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 60888b1a6fd..0603d7453e1 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -105,6 +105,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") + const messageInitialSize = 50 const messagePageSize = 400 const inflight = new Map>() const inflightDiff = new Map>() @@ -122,11 +123,6 @@ 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 }), @@ -150,6 +146,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore: Setter sessionID: string limit: number + fresh?: boolean }) => { const key = keyFor(input.directory, input.sessionID) if (meta.loading[key]) return @@ -157,14 +154,48 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setMeta("loading", key, true) await fetchMessages(input) .then((next) => { + // Phase 1: set messages + metadata (triggers timeline render) 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" })) + if (input.fresh) { + input.setStore("message", input.sessionID, next.session) + } else { + input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" })) } setMeta("limit", key, input.limit) setMeta("complete", key, next.complete) }) + + // Phase 2: load parts in chunks with frame/task yielding so the UI can paint between batches + const chunkSize = 50 + const parts = next.part + const scheduler = typeof globalThis.requestAnimationFrame === "function" + ? ("raf" as const) + : ("timeout" as const) + let i = 0 + const scheduleChunk = (task: () => void) => { + const hidden = typeof globalThis.document !== "undefined" && globalThis.document.visibilityState === "hidden" + if (scheduler === "raf" && !hidden) { + globalThis.requestAnimationFrame(() => task()) + return + } + globalThis.setTimeout(task, 0) + } + const loadPartChunk = () => { + batch(() => { + const end = Math.min(i + chunkSize, parts.length) + for (; i < end; i++) { + input.setStore("part", parts[i].id, parts[i].part) + } + }) + if (i < parts.length) { + scheduleChunk(loadPartChunk) + return + } + } + if (parts.length > 0) { + scheduleChunk(loadPartChunk) + return + } }) .finally(() => { setMeta("loading", key, false) @@ -238,8 +269,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ 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 cached = store.message[sessionID] ?? [] + const count = cached.length + const hasParts = cached.every((message) => store.part[message.id] !== undefined) + const warm = hasMessages && !hydrated && count >= messageInitialSize && hasParts + const limit = hydrated + ? (meta.limit[key] ?? messagePageSize) + : warm + ? Math.max(count, messageInitialSize) + : messageInitialSize + if (warm) { + setMeta("limit", key, limit) + setMeta("complete", key, count < messageInitialSize) + } const sessionReq = hasSession ? Promise.resolve() @@ -260,7 +302,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) const messagesReq = - hasMessages && hydrated + hasMessages && (hydrated || warm) ? Promise.resolve() : loadMessages({ directory, @@ -268,6 +310,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore, sessionID, limit, + fresh: !hasMessages, }) return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) @@ -281,7 +324,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(directory, sessionID) return runInflight(inflightDiff, key, () => retry(() => client.session.diff({ sessionID })).then((diff) => { - setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) + const files = diff.data ?? [] + setStore("session_diff", sessionID, reconcile(files, { key: "file" })) }), ) }, @@ -289,7 +333,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) + const key = keyFor(directory, sessionID) const existing = store.todo[sessionID] + const cached = globalSync.data.session_todo[sessionID] if (existing !== undefined) { if (globalSync.data.session_todo[sessionID] === undefined) { globalSync.todo.set(sessionID, existing) @@ -297,12 +343,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return } - const cached = globalSync.data.session_todo[sessionID] if (cached !== undefined) { setStore("todo", sessionID, reconcile(cached, { key: "id" })) } - const key = keyFor(directory, sessionID) return runInflight(inflightTodo, key, () => retry(() => client.session.todo({ sessionID })).then((todo) => { const list = todo.data ?? [] @@ -333,12 +377,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (meta.complete[key]) return const currentLimit = meta.limit[key] ?? messagePageSize + const nextLimit = currentLimit + count await loadMessages({ directory, client, setStore, sessionID, - limit: currentLimit + count, + limit: nextLimit, }) }, }, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5ef68cc5c57..f780517bc95 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" @@ -209,8 +209,13 @@ export default function Page() { ), ) + const turnInit = 10 + const turnBatch = 20 + const turnScrollThreshold = 200 + const [store, setStore] = createStore({ messageId: undefined as string | undefined, + turnID: undefined as string | undefined, turnStart: 0, mobileTab: "session" as "session" | "changes", changes: "session" as "session" | "turn", @@ -220,12 +225,31 @@ export default function Page() { const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) + const turnStart = createMemo(() => { + const id = params.id + const len = visibleUserMessages().length + if (!id || len <= 0) return 0 + if (store.turnID !== id) return len > turnInit ? len - turnInit : 0 + if (store.turnStart <= 0) return 0 + if (store.turnStart >= len) return Math.max(0, len - turnInit) + return store.turnStart + }) + + const setTurnStart = (start: number) => { + const id = params.id + const next = start > 0 ? start : 0 + if (!id) { + setStore({ turnID: undefined, turnStart: next }) + return + } + setStore({ turnID: id, turnStart: next }) + } + const renderedUserMessages = createMemo( () => { const msgs = visibleUserMessages() - const start = store.turnStart + const start = turnStart() if (start <= 0) return msgs - if (start >= msgs.length) return emptyUserMessages return msgs.slice(start) }, emptyUserMessages, @@ -302,13 +326,19 @@ 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}\n${params.id ?? ""}`, + (k) => { + const [, id] = k.split("\n") + if (!id) return + untrack(() => { + void sync.session.sync(id) + void sync.session.todo(id) + }) + }, + ), + ) createEffect( on( @@ -894,46 +924,8 @@ 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 + const start = turnStart() if (start <= 0) return const next = start - turnBatch @@ -941,37 +933,42 @@ export default function Page() { const el = scroller if (!el) { - setStore("turnStart", nextStart) - scheduleTurnBackfill() + setTurnStart(nextStart) return } const beforeTop = el.scrollTop const beforeHeight = el.scrollHeight - setStore("turnStart", nextStart) + setTurnStart(nextStart) requestAnimationFrame(() => { const delta = el.scrollHeight - beforeHeight if (!delta) return el.scrollTop = beforeTop + delta }) + } - scheduleTurnBackfill() + function onScrollerScroll() { + if (!autoScroll.userScrolled()) return + const el = scroller + if (!el) return + const start = turnStart() + if (start <= 0) return + if (el.scrollTop < turnScrollThreshold) { + backfillTurns() + } } 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() + setTurnStart(start) }, { defer: true }, ), @@ -1002,13 +999,12 @@ export default function Page() { sessionID: () => params.id, messagesReady, visibleUserMessages, - turnStart: () => store.turnStart, + turnStart, currentMessageId: () => store.messageId, pendingMessage: () => ui.pendingMessage, setPendingMessage: (value) => setUi("pendingMessage", value), setActiveMessage, - setTurnStart: (value) => setStore("turnStart", value), - scheduleTurnBackfill, + setTurnStart, autoScroll, scroller: () => scroller, anchor, @@ -1021,7 +1017,6 @@ export default function Page() { }) onCleanup(() => { - cancelTurnBackfill() document.removeEventListener("keydown", handleKeyDown) scrollSpy.destroy() if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) @@ -1076,6 +1071,7 @@ export default function Page() { hasScrollGesture={hasScrollGesture} isDesktop={isDesktop()} onScrollSpyScroll={scrollSpy.onScroll} + onTurnBackfillScroll={onScrollerScroll} onAutoScrollInteraction={autoScroll.handleInteraction} centered={centered()} setContentRef={(el) => { @@ -1085,14 +1081,14 @@ export default function Page() { const root = scroller if (root) scheduleScrollState(root) }} - turnStart={store.turnStart} - onRenderEarlier={() => setStore("turnStart", 0)} + turnStart={turnStart()} + onRenderEarlier={() => setTurnStart(0)} historyMore={historyMore()} historyLoading={historyLoading()} onLoadEarlier={() => { const id = params.id if (!id) return - setStore("turnStart", 0) + setTurnStart(0) sync.session.history.loadMore(id) }} renderedUserMessages={renderedUserMessages()} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index d2de720a312..f58cc26ba38 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" @@ -93,6 +93,7 @@ export function MessageTimeline(props: { hasScrollGesture: () => boolean isDesktop: boolean onScrollSpyScroll: () => void + onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void centered: boolean setContentRef: (el: HTMLDivElement) => void @@ -126,6 +127,42 @@ export function MessageTimeline(props: { const titleValue = createMemo(() => info()?.title) const parentID = createMemo(() => info()?.parentID) const showHeader = createMemo(() => !!(titleValue() || parentID())) + const stageCfg = { + init: 3, + batch: 1, + max: 10, + } + const stageKey = createMemo(() => { + const list = props.renderedUserMessages + const total = list.length + const head = total > 0 ? list[0].id : "" + const tail = total > 0 ? list[total - 1].id : "" + return `${sessionKey()}\n${props.turnStart}\n${total}\n${head}\n${tail}` + }) + const stageEnabled = createMemo(() => { + const total = props.renderedUserMessages.length + return props.turnStart > 0 && total > stageCfg.init && total <= stageCfg.max + }) + const stageInitial = createMemo(() => Math.min(props.renderedUserMessages.length, stageCfg.init)) + const [staging, setStaging] = createStore({ + key: "", + count: 0, + }) + const stagedCount = createMemo(() => { + const total = props.renderedUserMessages.length + if (!stageEnabled()) return total + if (staging.key !== stageKey()) return stageInitial() + if (staging.count <= stageInitial()) return stageInitial() + if (staging.count >= total) return total + return staging.count + }) + const stagedUserMessages = createMemo(() => { + const list = props.renderedUserMessages + const count = stagedCount() + if (count >= list.length) return list + return list.slice(Math.max(0, list.length - count)) + }) + let stageFrame: number | undefined const [title, setTitle] = createStore({ draft: "", @@ -153,6 +190,57 @@ export function MessageTimeline(props: { ), ) + createEffect( + on( + stageKey, + () => { + if (stageFrame !== undefined) { + cancelAnimationFrame(stageFrame) + stageFrame = undefined + } + + const id = sessionID() + const total = props.renderedUserMessages.length + if (!id) { + setStaging({ key: "", count: total }) + return + } + + const key = stageKey() + if (!stageEnabled()) { + setStaging({ key, count: total }) + return + } + + const init = stageInitial() + setStaging({ key, count: init }) + + const step = () => { + if (staging.key !== key) { + stageFrame = undefined + return + } + const next = Math.min(total, staging.count + stageCfg.batch) + startTransition(() => { + setStaging("count", next) + }) + if (next >= total) { + stageFrame = undefined + return + } + stageFrame = requestAnimationFrame(step) + } + + stageFrame = requestAnimationFrame(step) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (stageFrame !== undefined) cancelAnimationFrame(stageFrame) + }) + const openTitleEditor = () => { if (!sessionID()) return setTitle({ editing: true, draft: titleValue() ?? "" }) @@ -392,6 +480,7 @@ export function MessageTimeline(props: { }} onScroll={(e) => { props.onScheduleScrollState(e.currentTarget) + props.onTurnBackfillScroll() if (!props.hasScrollGesture()) return props.onAutoScrollHandleScroll() props.onMarkScrollGesture(e.currentTarget) @@ -551,9 +640,10 @@ export function MessageTimeline(props: { - + {(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)) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index e8db405fddd..d9fe5710b1d 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -519,15 +519,7 @@ export namespace Session { sessionID: Identifier.schema("session"), limit: z.number().optional(), }), - async (input) => { - const result = [] as MessageV2.WithParts[] - for await (const msg of MessageV2.stream(input.sessionID)) { - if (input.limit && result.length >= input.limit) break - result.push(msg) - } - result.reverse() - return result - }, + async (input) => MessageV2.list(input), ) export function* list(input?: { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 178751a2227..74cb35ff778 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -731,26 +731,27 @@ export namespace MessageV2 { const ids = rows.map((row) => row.id) const partsByMessage = new Map() - if (ids.length > 0) { - const partRows = Database.use((db) => - db - .select() - .from(PartTable) - .where(inArray(PartTable.message_id, ids)) - .orderBy(PartTable.message_id, PartTable.id) - .all(), - ) - for (const row of partRows) { - const part = { - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - } as MessageV2.Part - const list = partsByMessage.get(row.message_id) - if (list) list.push(part) - else partsByMessage.set(row.message_id, [part]) - } + const partRows = + ids.length > 0 + ? Database.use((db) => + db + .select() + .from(PartTable) + .where(inArray(PartTable.message_id, ids)) + .orderBy(PartTable.message_id, PartTable.id) + .all(), + ) + : [] + for (const row of partRows) { + const part = { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + const list = partsByMessage.get(row.message_id) + if (list) list.push(part) + else partsByMessage.set(row.message_id, [part]) } for (const row of rows) { @@ -766,6 +767,62 @@ export namespace MessageV2 { } }) + export const list = fn( + z.object({ + sessionID: Identifier.schema("session"), + limit: z.number().optional(), + }), + async (input) => { + // Fetch newest messages first (DESC), then reverse for chronological order + const messageRows = Database.use((db) => + input.limit + ? db + .select() + .from(MessageTable) + .where(eq(MessageTable.session_id, input.sessionID)) + .orderBy(desc(MessageTable.time_created)) + .limit(input.limit) + .all() + : db + .select() + .from(MessageTable) + .where(eq(MessageTable.session_id, input.sessionID)) + .orderBy(desc(MessageTable.time_created)) + .all(), + ) + if (messageRows.length === 0) return [] + messageRows.reverse() + + const ids = messageRows.map((row) => row.id) + const partsByMessage = new Map() + const partRows = Database.use((db) => + db + .select() + .from(PartTable) + .where(inArray(PartTable.message_id, ids)) + .orderBy(PartTable.message_id, PartTable.id) + .all(), + ) + for (const row of partRows) { + const part = { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + const arr = partsByMessage.get(row.message_id) + if (arr) arr.push(part) + else partsByMessage.set(row.message_id, [part]) + } + + const result = messageRows.map((row) => ({ + info: { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info, + parts: partsByMessage.get(row.id) ?? [], + })) + return result + }, + ) + export const parts = fn(Identifier.schema("message"), async (message_id) => { const rows = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), From 2a6cb1263ab9d6b8147595c4589091c353ae7ba1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Feb 2026 13:13:02 -0500 Subject: [PATCH 02/10] refactor(app): clarify staged timeline flow and sync heuristics --- packages/app/src/context/sync.tsx | 2 + packages/app/src/pages/session.tsx | 3 + .../src/pages/session/message-timeline.tsx | 66 +++++++++++-------- 3 files changed, 43 insertions(+), 28 deletions(-) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 0603d7453e1..b33b2460f50 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -272,6 +272,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const cached = store.message[sessionID] ?? [] const count = cached.length const hasParts = cached.every((message) => store.part[message.id] !== undefined) + // "Warm" means we already have enough local messages + parts to render + // immediately, so we can skip a blocking message fetch on switch. const warm = hasMessages && !hydrated && count >= messageInitialSize && hasParts const limit = hydrated ? (meta.limit[key] ?? messagePageSize) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index f780517bc95..4bc2fa2b3d8 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -225,6 +225,8 @@ export default function Page() { const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) + // Keep turn window state per session so first render can be capped synchronously + // when switching sessions. const turnStart = createMemo(() => { const id = params.id const len = visibleUserMessages().length @@ -955,6 +957,7 @@ export default function Page() { if (!el) return const start = turnStart() if (start <= 0) return + // Backfill older turns only when the user intentionally scrolls near the top. if (el.scrollTop < turnScrollThreshold) { backfillTurns() } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index f58cc26ba38..f761a042efe 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -132,37 +132,50 @@ export function MessageTimeline(props: { batch: 1, max: 10, } - const stageKey = createMemo(() => { + // We only stage small deferred windows so first paint mounts quickly + // (few turns first, then grow to full initial window). + const stageView = createMemo(() => { const list = props.renderedUserMessages const total = list.length - const head = total > 0 ? list[0].id : "" - const tail = total > 0 ? list[total - 1].id : "" - return `${sessionKey()}\n${props.turnStart}\n${total}\n${head}\n${tail}` + const enabled = props.turnStart > 0 && total > stageCfg.init && total <= stageCfg.max + return { + list, + total, + enabled, + initial: enabled ? Math.min(total, stageCfg.init) : total, + head: total > 0 ? list[0].id : "", + tail: total > 0 ? list[total - 1].id : "", + } }) - const stageEnabled = createMemo(() => { - const total = props.renderedUserMessages.length - return props.turnStart > 0 && total > stageCfg.init && total <= stageCfg.max + // Use a bounded signature (count + endpoints), not all IDs. + const stageKey = createMemo(() => { + const view = stageView() + return `${sessionKey()}\n${props.turnStart}\n${view.total}\n${view.head}\n${view.tail}` }) - const stageInitial = createMemo(() => Math.min(props.renderedUserMessages.length, stageCfg.init)) const [staging, setStaging] = createStore({ key: "", count: 0, }) const stagedCount = createMemo(() => { - const total = props.renderedUserMessages.length - if (!stageEnabled()) return total - if (staging.key !== stageKey()) return stageInitial() - if (staging.count <= stageInitial()) return stageInitial() - if (staging.count >= total) return total + const view = stageView() + if (!view.enabled) return view.total + if (staging.key !== stageKey()) return view.initial + if (staging.count <= view.initial) return view.initial + if (staging.count >= view.total) return view.total return staging.count }) const stagedUserMessages = createMemo(() => { - const list = props.renderedUserMessages + const list = stageView().list const count = stagedCount() if (count >= list.length) return list return list.slice(Math.max(0, list.length - count)) }) let stageFrame: number | undefined + const cancelStage = () => { + if (stageFrame === undefined) return + cancelAnimationFrame(stageFrame) + stageFrame = undefined + } const [title, setTitle] = createStore({ draft: "", @@ -194,37 +207,34 @@ export function MessageTimeline(props: { on( stageKey, () => { - if (stageFrame !== undefined) { - cancelAnimationFrame(stageFrame) - stageFrame = undefined - } + cancelStage() const id = sessionID() - const total = props.renderedUserMessages.length + const view = stageView() if (!id) { - setStaging({ key: "", count: total }) + setStaging({ key: "", count: view.total }) return } const key = stageKey() - if (!stageEnabled()) { - setStaging({ key, count: total }) + if (!view.enabled) { + setStaging({ key, count: view.total }) return } - const init = stageInitial() - setStaging({ key, count: init }) + let count = view.initial + setStaging({ key, count }) const step = () => { if (staging.key !== key) { stageFrame = undefined return } - const next = Math.min(total, staging.count + stageCfg.batch) + count = Math.min(view.total, count + stageCfg.batch) startTransition(() => { - setStaging("count", next) + setStaging("count", count) }) - if (next >= total) { + if (count >= view.total) { stageFrame = undefined return } @@ -238,7 +248,7 @@ export function MessageTimeline(props: { ) onCleanup(() => { - if (stageFrame !== undefined) cancelAnimationFrame(stageFrame) + cancelStage() }) const openTitleEditor = () => { From 5afbb58c659dc82a7957279dc1c5214142dc8cb4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Feb 2026 20:09:59 -0500 Subject: [PATCH 03/10] refactor(session): simplify sync and deduplicate message assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync.tsx: - Remove redundant .filter((m) => !!m?.id) in fetchMessages — the preceding filter already guarantees x.info.id is truthy, so this was a wasted array allocation + iteration on every session switch. Removing it noticeably speeds up large session loads. - Simplify part loading from a multi-chunk rAF/setTimeout scheduler to a single requestAnimationFrame + batch. The chunking was needed when parts used reconcile, but with direct assignment the cost is low enough to do in one pass. Still defers to the next frame so message skeletons paint first. - Remove fresh flag — reconcile against empty is cheap enough that the conditional wasn't worth the complexity. - Minor cleanups: if/else instead of two independent ifs in applyOptimisticAdd, inline hasSession IIFE, use cached variable in todo() instead of re-reading globalSync, inline nextLimit. message-v2.ts: - Extract fetchParts() and assembleMessages() helpers shared by stream() and list(), eliminating duplicated Map-building and row-to-WithParts assembly logic. --- packages/app/src/context/sync.tsx | 63 ++++---------- packages/opencode/src/session/message-v2.ts | 94 +++++++++------------ 2 files changed, 53 insertions(+), 104 deletions(-) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index b33b2460f50..0474bcdd44d 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) } @@ -130,7 +129,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ 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 part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) return { @@ -146,7 +144,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore: Setter sessionID: string limit: number - fresh?: boolean }) => { const key = keyFor(input.directory, input.sessionID) if (meta.loading[key]) return @@ -156,45 +153,21 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .then((next) => { // Phase 1: set messages + metadata (triggers timeline render) batch(() => { - if (input.fresh) { - input.setStore("message", input.sessionID, next.session) - } else { - input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" })) - } + input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" })) setMeta("limit", key, input.limit) setMeta("complete", key, next.complete) }) - // Phase 2: load parts in chunks with frame/task yielding so the UI can paint between batches - const chunkSize = 50 + // Phase 2: load parts after a frame so the browser can paint message skeletons first const parts = next.part - const scheduler = typeof globalThis.requestAnimationFrame === "function" - ? ("raf" as const) - : ("timeout" as const) - let i = 0 - const scheduleChunk = (task: () => void) => { - const hidden = typeof globalThis.document !== "undefined" && globalThis.document.visibilityState === "hidden" - if (scheduler === "raf" && !hidden) { - globalThis.requestAnimationFrame(() => task()) - return - } - globalThis.setTimeout(task, 0) - } - const loadPartChunk = () => { - batch(() => { - const end = Math.min(i + chunkSize, parts.length) - for (; i < end; i++) { - input.setStore("part", parts[i].id, parts[i].part) - } - }) - if (i < parts.length) { - scheduleChunk(loadPartChunk) - return - } - } if (parts.length > 0) { - scheduleChunk(loadPartChunk) - return + requestAnimationFrame(() => { + batch(() => { + for (const p of parts) { + input.setStore("part", p.id, p.part) + } + }) + }) } }) .finally(() => { @@ -260,10 +233,7 @@ 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 @@ -312,7 +282,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore, sessionID, limit, - fresh: !hasMessages, }) return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) @@ -326,8 +295,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(directory, sessionID) return runInflight(inflightDiff, key, () => retry(() => client.session.diff({ sessionID })).then((diff) => { - const files = diff.data ?? [] - setStore("session_diff", sessionID, reconcile(files, { key: "file" })) + setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" })) }), ) }, @@ -339,7 +307,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ 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 @@ -379,13 +347,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (meta.complete[key]) return const currentLimit = meta.limit[key] ?? messagePageSize - const nextLimit = currentLimit + count await loadMessages({ directory, client, setStore, sessionID, - limit: nextLimit, + limit: currentLimit + count, }) }, }, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 74cb35ff778..70478e43b7f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -713,6 +713,40 @@ export namespace MessageV2 { ) } + type MessageRow = typeof MessageTable.$inferSelect + type PartRow = typeof PartTable.$inferSelect + + function fetchParts(ids: string[]): PartRow[] { + if (ids.length === 0) return [] + return Database.use((db) => + db + .select() + .from(PartTable) + .where(inArray(PartTable.message_id, ids)) + .orderBy(PartTable.message_id, PartTable.id) + .all(), + ) + } + + function assembleMessages(messageRows: MessageRow[], partRows: PartRow[]): WithParts[] { + const partsByMessage = new Map() + for (const row of partRows) { + const part = { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + const arr = partsByMessage.get(row.message_id) + if (arr) arr.push(part) + else partsByMessage.set(row.message_id, [part]) + } + return messageRows.map((row) => ({ + info: { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info, + parts: partsByMessage.get(row.id) ?? [], + })) + } + export const stream = fn(Identifier.schema("session"), async function* (sessionID) { const size = 50 let offset = 0 @@ -730,36 +764,9 @@ export namespace MessageV2 { if (rows.length === 0) break const ids = rows.map((row) => row.id) - const partsByMessage = new Map() - const partRows = - ids.length > 0 - ? Database.use((db) => - db - .select() - .from(PartTable) - .where(inArray(PartTable.message_id, ids)) - .orderBy(PartTable.message_id, PartTable.id) - .all(), - ) - : [] - for (const row of partRows) { - const part = { - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - } as MessageV2.Part - const list = partsByMessage.get(row.message_id) - if (list) list.push(part) - else partsByMessage.set(row.message_id, [part]) - } - - for (const row of rows) { - const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info - yield { - info, - parts: partsByMessage.get(row.id) ?? [], - } + const assembled = assembleMessages(rows, fetchParts(ids)) + for (const msg of assembled) { + yield msg } offset += rows.length @@ -794,32 +801,7 @@ export namespace MessageV2 { messageRows.reverse() const ids = messageRows.map((row) => row.id) - const partsByMessage = new Map() - const partRows = Database.use((db) => - db - .select() - .from(PartTable) - .where(inArray(PartTable.message_id, ids)) - .orderBy(PartTable.message_id, PartTable.id) - .all(), - ) - for (const row of partRows) { - const part = { - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - } as MessageV2.Part - const arr = partsByMessage.get(row.message_id) - if (arr) arr.push(part) - else partsByMessage.set(row.message_id, [part]) - } - - const result = messageRows.map((row) => ({ - info: { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info, - parts: partsByMessage.get(row.id) ?? [], - })) - return result + return assembleMessages(messageRows, fetchParts(ids)) }, ) From 65d242b5aae070edd5f3ce0cebcd8ce6e661ace8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 28 Feb 2026 20:30:05 -0500 Subject: [PATCH 04/10] refactor(sync): simplify message loading and add debug logging Remove the two-phase deferred parts loading (requestAnimationFrame) in favor of a single batch that loads messages and parts together. Add temporary debug logging to loadMessages, sync, and history.more to trace message fetch behavior during session switching. --- packages/app/src/context/sync.tsx | 36 +++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 0474bcdd44d..f758e58bada 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -151,24 +151,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setMeta("loading", key, true) await fetchMessages(input) .then((next) => { - // Phase 1: set messages + metadata (triggers timeline render) + const userCount = next.session.filter((m) => m.role === "user").length + console.log(`[sync] loadMessages sessionID=${input.sessionID.slice(0, 12)}… limit=${input.limit} fetched=${next.session.length} userMsgs=${userCount} complete=${next.complete}`) batch(() => { input.setStore("message", input.sessionID, reconcile(next.session, { 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) }) - - // Phase 2: load parts after a frame so the browser can paint message skeletons first - const parts = next.part - if (parts.length > 0) { - requestAnimationFrame(() => { - batch(() => { - for (const p of parts) { - input.setStore("part", p.id, p.part) - } - }) - }) - } }) .finally(() => { setMeta("loading", key, false) @@ -237,7 +229,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const hasMessages = store.message[sessionID] !== undefined const hydrated = meta.limit[key] !== undefined - if (hasSession && hasMessages && hydrated) return + if (hasSession && hasMessages && hydrated) { + console.log(`[sync] sync sessionID=${sessionID.slice(0, 12)}… already hydrated, skipping`) + return + } const cached = store.message[sessionID] ?? [] const count = cached.length @@ -250,6 +245,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ : warm ? Math.max(count, messageInitialSize) : messageInitialSize + + console.log(`[sync] sync sessionID=${sessionID.slice(0, 12)}… hasSession=${hasSession} hasMessages=${hasMessages} hydrated=${hydrated} cached=${count} hasParts=${hasParts} warm=${warm} limit=${limit}`) + if (warm) { setMeta("limit", key, limit) setMeta("complete", key, count < messageInitialSize) @@ -329,10 +327,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ more(sessionID: string) { const store = current()[0] const key = keyFor(sdk.directory, sessionID) - if (store.message[sessionID] === undefined) return false - if (meta.limit[key] === undefined) return false - if (meta.complete[key]) return false - return true + const hasMessages = store.message[sessionID] !== undefined + const hasLimit = meta.limit[key] !== undefined + const complete = meta.complete[key] ?? false + const result = hasMessages && hasLimit && !complete + console.log(`[sync] history.more sessionID=${sessionID.slice(0, 12)}… hasMessages=${hasMessages} hasLimit=${hasLimit} complete=${complete} → ${result}`) + return result }, loading(sessionID: string) { const key = keyFor(sdk.directory, sessionID) From 3bf39d11f87b01148beba6f7c717c4bd0e9185a4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 1 Mar 2026 11:35:45 -0500 Subject: [PATCH 05/10] refactor(session): isolate history window and sync policy Extract history window and timeline staging controllers so session switching and scroll-up reveal behavior are easier to reason about and tune. Simplify session sync fetch planning by removing warm-path branching in favor of a single explicit policy. --- packages/app/src/context/sync.tsx | 99 +++--- packages/app/src/pages/session.tsx | 314 ++++++++++++------ .../src/pages/session/message-timeline.tsx | 225 +++++++------ 3 files changed, 385 insertions(+), 253 deletions(-) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index f758e58bada..15d50b9d017 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -25,6 +25,37 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}` const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) +type SessionSyncPlanInput = { + hasSession: boolean + hasMessages: boolean + hydrated: boolean + limit: number | undefined + messageInitialSize: number + messagePageSize: number +} + +/** + * Computes fetch policy for `session.sync`. + * + * A hydrated session can skip re-fetching messages. Otherwise we bootstrap + * with the initial fetch size and let pagination grow from there. + */ +function sessionSyncPlan(input: SessionSyncPlanInput) { + if (input.hasSession && input.hasMessages && input.hydrated) { + return { + done: true, + skipMessages: true, + limit: input.limit ?? input.messagePageSize, + } + } + + return { + done: false, + skipMessages: input.hasMessages && input.hydrated, + limit: input.hydrated ? (input.limit ?? input.messagePageSize) : input.messageInitialSize, + } +} + type OptimisticStore = { message: Record part: Record @@ -104,7 +135,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messageInitialSize = 50 + const messageInitialSize = 200 const messagePageSize = 400 const inflight = new Map>() const inflightDiff = new Map>() @@ -127,9 +158,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ 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) - .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, @@ -151,8 +180,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setMeta("loading", key, true) await fetchMessages(input) .then((next) => { - const userCount = next.session.filter((m) => m.role === "user").length - console.log(`[sync] loadMessages sessionID=${input.sessionID.slice(0, 12)}… limit=${input.limit} fetched=${next.session.length} userMsgs=${userCount} complete=${next.complete}`) batch(() => { input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" })) for (const p of next.part) { @@ -229,29 +256,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const hasMessages = store.message[sessionID] !== undefined const hydrated = meta.limit[key] !== undefined - if (hasSession && hasMessages && hydrated) { - console.log(`[sync] sync sessionID=${sessionID.slice(0, 12)}… already hydrated, skipping`) - return - } - - const cached = store.message[sessionID] ?? [] - const count = cached.length - const hasParts = cached.every((message) => store.part[message.id] !== undefined) - // "Warm" means we already have enough local messages + parts to render - // immediately, so we can skip a blocking message fetch on switch. - const warm = hasMessages && !hydrated && count >= messageInitialSize && hasParts - const limit = hydrated - ? (meta.limit[key] ?? messagePageSize) - : warm - ? Math.max(count, messageInitialSize) - : messageInitialSize - - console.log(`[sync] sync sessionID=${sessionID.slice(0, 12)}… hasSession=${hasSession} hasMessages=${hasMessages} hydrated=${hydrated} cached=${count} hasParts=${hasParts} warm=${warm} limit=${limit}`) - - if (warm) { - setMeta("limit", key, limit) - setMeta("complete", key, count < messageInitialSize) - } + const plan = sessionSyncPlan({ + hasSession, + hasMessages, + hydrated, + limit: meta.limit[key], + messageInitialSize, + messagePageSize, + }) + if (plan.done) return const sessionReq = hasSession ? Promise.resolve() @@ -271,16 +284,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) }) - const messagesReq = - hasMessages && (hydrated || warm) - ? Promise.resolve() - : loadMessages({ - directory, - client, - setStore, - sessionID, - limit, - }) + const messagesReq = plan.skipMessages + ? Promise.resolve() + : loadMessages({ + directory, + client, + setStore, + sessionID, + limit: plan.limit, + }) return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) }, @@ -330,19 +342,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const hasMessages = store.message[sessionID] !== undefined const hasLimit = meta.limit[key] !== undefined const complete = meta.complete[key] ?? false - const result = hasMessages && hasLimit && !complete - console.log(`[sync] history.more sessionID=${sessionID.slice(0, 12)}… hasMessages=${hasMessages} hasLimit=${hasLimit} complete=${complete} → ${result}`) - return result + return hasMessages && hasLimit && !complete }, loading(sessionID: string) { 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 @@ -352,7 +363,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 4bc2fa2b3d8..b602c36bf13 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -32,6 +32,203 @@ 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" +type LoadEarlierSource = "scroll" | "button" | "buffer" + +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 turnStart = createMemo(() => { + const id = input.sessionID() + const len = input.visibleUserMessages().length + if (!id || len <= 0) return 0 + if (state.turnID !== id) return len > turnInit ? len - turnInit : 0 + if (state.turnStart <= 0) return 0 + if (state.turnStart >= len) return Math.max(0, len - turnInit) + 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 emptyUserMessages: UserMessage[] = [] + 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)) + } + + const loadEarlier = async (opts?: { + source?: LoadEarlierSource + revealCached?: boolean + revealAfterLoad?: boolean + }) => { + const source = opts?.source ?? "scroll" + const revealCached = opts?.revealCached ?? false + const revealAfterLoad = opts?.revealAfterLoad ?? true + const id = input.sessionID() + if (!id) return + + const start = turnStart() + const beforeVisible = input.visibleUserMessages().length + const beforeRendered = revealCached || start <= 0 ? beforeVisible : renderedUserMessages().length + const expectedStart = revealCached && start > 0 ? 0 : start + if (revealCached && start > 0) { + setTurnStart(0) + } + + if (!input.historyMore() || input.historyLoading()) return + + if (source === "buffer") { + const now = Date.now() + if (state.prefetchUntil > now) return + if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return + setState("prefetchUntil", now + prefetchCooldownMs) + } + + await input.loadMore(id) + if (input.sessionID() !== id) return + + const afterVisible = input.visibleUserMessages().length + const growth = afterVisible - beforeVisible + if (source === "buffer") { + const noGrowth = growth > 0 ? 0 : state.prefetchNoGrowth + 1 + setState("prefetchNoGrowth", noGrowth) + } + if (source !== "buffer" && growth > 0 && state.prefetchNoGrowth) { + setState("prefetchNoGrowth", 0) + } + if (growth <= 0) return + + const currentStart = turnStart() + if (currentStart !== expectedStart) return + + const currentRendered = renderedUserMessages().length + const base = Math.max(beforeRendered, currentRendered) + const target = revealAfterLoad ? 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 loadEarlier({ source: "buffer", revealAfterLoad: false }) + } + backfillTurns() + return + } + + void loadEarlier({ source: "scroll" }) + } + + 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 + + const len = input.visibleUserMessages().length + const start = len > turnInit ? len - turnInit : 0 + setTurnStart(start) + }, + { defer: true }, + ), + ) + + return { + turnStart, + setTurnStart, + renderedUserMessages, + loadEarlier, + onScrollerScroll, + } +} + export default function Page() { const layout = useLayout() const local = useLocal() @@ -209,14 +406,8 @@ export default function Page() { ), ) - const turnInit = 10 - const turnBatch = 20 - const turnScrollThreshold = 200 - const [store, setStore] = createStore({ messageId: undefined as string | undefined, - turnID: undefined as string | undefined, - turnStart: 0, mobileTab: "session" as "session" | "changes", changes: "session" as "session" | "turn", newSessionWorktree: "main", @@ -225,41 +416,6 @@ export default function Page() { const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) - // Keep turn window state per session so first render can be capped synchronously - // when switching sessions. - const turnStart = createMemo(() => { - const id = params.id - const len = visibleUserMessages().length - if (!id || len <= 0) return 0 - if (store.turnID !== id) return len > turnInit ? len - turnInit : 0 - if (store.turnStart <= 0) return 0 - if (store.turnStart >= len) return Math.max(0, len - turnInit) - return store.turnStart - }) - - const setTurnStart = (start: number) => { - const id = params.id - const next = start > 0 ? start : 0 - if (!id) { - setStore({ turnID: undefined, turnStart: next }) - return - } - setStore({ turnID: id, turnStart: next }) - } - - const renderedUserMessages = createMemo( - () => { - const msgs = visibleUserMessages() - const start = turnStart() - if (start <= 0) return msgs - return msgs.slice(start) - }, - emptyUserMessages, - { - equals: same, - }, - ) - const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" const project = sync.project @@ -926,56 +1082,16 @@ export default function Page() { }, ) - function backfillTurns() { - const start = turnStart() - if (start <= 0) return - - const next = start - turnBatch - const nextStart = next > 0 ? next : 0 - - const el = scroller - if (!el) { - setTurnStart(nextStart) - return - } - - const beforeTop = el.scrollTop - const beforeHeight = el.scrollHeight - - setTurnStart(nextStart) - - requestAnimationFrame(() => { - const delta = el.scrollHeight - beforeHeight - if (!delta) return - el.scrollTop = beforeTop + delta - }) - } - - function onScrollerScroll() { - if (!autoScroll.userScrolled()) return - const el = scroller - if (!el) return - const start = turnStart() - if (start <= 0) return - // Backfill older turns only when the user intentionally scrolls near the top. - if (el.scrollTop < turnScrollThreshold) { - backfillTurns() - } - } - - createEffect( - on( - () => [params.id, messagesReady()] as const, - ([id, ready]) => { - if (!id || !ready) return - - const len = visibleUserMessages().length - const start = len > turnInit ? len - turnInit : 0 - setTurnStart(start) - }, - { 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,12 +1118,12 @@ export default function Page() { sessionID: () => params.id, messagesReady, visibleUserMessages, - turnStart, + turnStart: historyWindow.turnStart, currentMessageId: () => store.messageId, pendingMessage: () => ui.pendingMessage, setPendingMessage: (value) => setUi("pendingMessage", value), setActiveMessage, - setTurnStart, + setTurnStart: historyWindow.setTurnStart, autoScroll, scroller: () => scroller, anchor, @@ -1074,7 +1190,7 @@ export default function Page() { hasScrollGesture={hasScrollGesture} isDesktop={isDesktop()} onScrollSpyScroll={scrollSpy.onScroll} - onTurnBackfillScroll={onScrollerScroll} + onTurnBackfillScroll={historyWindow.onScrollerScroll} onAutoScrollInteraction={autoScroll.handleInteraction} centered={centered()} setContentRef={(el) => { @@ -1084,17 +1200,13 @@ export default function Page() { const root = scroller if (root) scheduleScrollState(root) }} - turnStart={turnStart()} - onRenderEarlier={() => setTurnStart(0)} + turnStart={historyWindow.turnStart()} historyMore={historyMore()} historyLoading={historyLoading()} onLoadEarlier={() => { - const id = params.id - if (!id) return - setTurnStart(0) - sync.session.history.loadMore(id) + void historyWindow.loadEarlier({ source: "button", revealCached: true }) }} - 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 f761a042efe..b9fc98864d4 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -81,6 +81,110 @@ const markBoundaryGesture = (input: { } } +type StageConfig = { + init: number + batch: number +} + +type TimelineStageInput = { + sessionKey: () => string + sessionID: () => string | undefined + 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 stageView = createMemo(() => { + const list = input.messages() + const total = list.length + return { list, total } + }) + + const [state, setState] = createStore({ + activeSession: "", + completedSession: "", + count: 0, + }) + + const stagedCount = createMemo(() => { + const { total } = stageView() + 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 = stageView().list + 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] as const, + ([sessionKey, hasMessages]) => { + cancel() + const { total } = stageView() + const shouldStage = + hasMessages && + total > input.config.init && + state.completedSession !== sessionKey && + state.activeSession !== sessionKey + if (!shouldStage) { + setState("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 = stageView().total + count = Math.min(currentTotal, count + input.config.batch) + startTransition(() => setState("count", count)) + if (count >= currentTotal) { + setState("completedSession", sessionKey) + 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 @@ -98,7 +202,6 @@ export function MessageTimeline(props: { centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number - onRenderEarlier: () => void historyMore: boolean historyLoading: boolean onLoadEarlier: () => void @@ -127,55 +230,14 @@ export function MessageTimeline(props: { const titleValue = createMemo(() => info()?.title) const parentID = createMemo(() => info()?.parentID) const showHeader = createMemo(() => !!(titleValue() || parentID())) - const stageCfg = { - init: 3, - batch: 1, - max: 10, - } - // We only stage small deferred windows so first paint mounts quickly - // (few turns first, then grow to full initial window). - const stageView = createMemo(() => { - const list = props.renderedUserMessages - const total = list.length - const enabled = props.turnStart > 0 && total > stageCfg.init && total <= stageCfg.max - return { - list, - total, - enabled, - initial: enabled ? Math.min(total, stageCfg.init) : total, - head: total > 0 ? list[0].id : "", - tail: total > 0 ? list[total - 1].id : "", - } - }) - // Use a bounded signature (count + endpoints), not all IDs. - const stageKey = createMemo(() => { - const view = stageView() - return `${sessionKey()}\n${props.turnStart}\n${view.total}\n${view.head}\n${view.tail}` - }) - const [staging, setStaging] = createStore({ - key: "", - count: 0, - }) - const stagedCount = createMemo(() => { - const view = stageView() - if (!view.enabled) return view.total - if (staging.key !== stageKey()) return view.initial - if (staging.count <= view.initial) return view.initial - if (staging.count >= view.total) return view.total - return staging.count - }) - const stagedUserMessages = createMemo(() => { - const list = stageView().list - const count = stagedCount() - if (count >= list.length) return list - return list.slice(Math.max(0, list.length - count)) + const stageCfg = { init: 1, batch: 1 } + const staging = createTimelineStaging({ + sessionKey, + sessionID, + turnStart: () => props.turnStart, + messages: () => props.renderedUserMessages, + config: stageCfg, }) - let stageFrame: number | undefined - const cancelStage = () => { - if (stageFrame === undefined) return - cancelAnimationFrame(stageFrame) - stageFrame = undefined - } const [title, setTitle] = createStore({ draft: "", @@ -203,54 +265,6 @@ export function MessageTimeline(props: { ), ) - createEffect( - on( - stageKey, - () => { - cancelStage() - - const id = sessionID() - const view = stageView() - if (!id) { - setStaging({ key: "", count: view.total }) - return - } - - const key = stageKey() - if (!view.enabled) { - setStaging({ key, count: view.total }) - return - } - - let count = view.initial - setStaging({ key, count }) - - const step = () => { - if (staging.key !== key) { - stageFrame = undefined - return - } - count = Math.min(view.total, count + stageCfg.batch) - startTransition(() => { - setStaging("count", count) - }) - if (count >= view.total) { - stageFrame = undefined - return - } - stageFrame = requestAnimationFrame(step) - } - - stageFrame = requestAnimationFrame(step) - }, - { defer: true }, - ), - ) - - onCleanup(() => { - cancelStage() - }) - const openTitleEditor = () => { if (!sessionID()) return setTitle({ editing: true, draft: titleValue() ?? "" }) @@ -440,8 +454,10 @@ export function MessageTimeline(props: {
-
- - + 0 || props.historyMore || props.historyLoading}>
- + {(message) => { const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? [])) const commentCount = createMemo(() => comments().length) From b834d1ca2ab3b6bdd31f01c81f5a91459605b7e8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 1 Mar 2026 12:02:03 -0500 Subject: [PATCH 06/10] perf(session): tune history fetch and restore stream path Lower initial and pagination message fetch sizes to reduce first-switch hydration cost and simplify timeline staging behavior during session switches. Revert session.messages back to the streaming implementation while we continue measuring UI switching performance with dev-only sync timing logs. --- packages/app/src/context/sync.tsx | 62 +++++++++++- .../src/pages/session/message-timeline.tsx | 20 ++-- packages/opencode/src/session/index.ts | 10 +- packages/opencode/src/session/message-v2.ts | 97 ++++++------------- 4 files changed, 102 insertions(+), 87 deletions(-) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 15d50b9d017..bdcd65ed23c 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -24,6 +24,12 @@ function runInflight(map: Map>, key: string, task: () => P const keyFor = (directory: string, id: string) => `${directory}\n${id}` const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) +const perfEnabled = import.meta.env.DEV +const perfNow = () => (typeof performance === "undefined" ? Date.now() : performance.now()) +const perfLog = (event: string, data: Record) => { + if (!perfEnabled) return + console.log(`[session-perf] ${event}`, data) +} type SessionSyncPlanInput = { hasSession: boolean @@ -135,8 +141,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messageInitialSize = 200 - const messagePageSize = 400 + const messageInitialSize = 100 + const messagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -177,6 +183,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(input.directory, input.sessionID) if (meta.loading[key]) return + const started = perfNow() setMeta("loading", key, true) await fetchMessages(input) .then((next) => { @@ -188,6 +195,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setMeta("limit", key, input.limit) setMeta("complete", key, next.complete) }) + const userCount = next.session.filter((m) => m.role === "user").length + perfLog("messages", { + sessionID: input.sessionID.slice(0, 12), + limit: input.limit, + fetched: next.session.length, + user: userCount, + complete: next.complete, + ms: Math.round(perfNow() - started), + }) + }) + .catch((error) => { + perfLog("messages.error", { + sessionID: input.sessionID.slice(0, 12), + limit: input.limit, + ms: Math.round(perfNow() - started), + error: error instanceof Error ? error.message : "unknown", + }) + throw error }) .finally(() => { setMeta("loading", key, false) @@ -254,6 +279,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(directory, sessionID) const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found + const started = perfNow() const hasMessages = store.message[sessionID] !== undefined const hydrated = meta.limit[key] !== undefined const plan = sessionSyncPlan({ @@ -264,7 +290,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ messageInitialSize, messagePageSize, }) - if (plan.done) return + if (plan.done) { + perfLog("sync.skip", { + sessionID: sessionID.slice(0, 12), + hasSession, + hasMessages, + hydrated, + }) + return + } + + perfLog("sync.start", { + sessionID: sessionID.slice(0, 12), + hasSession, + hasMessages, + hydrated, + skipMessages: plan.skipMessages, + limit: plan.limit, + }) const sessionReq = hasSession ? Promise.resolve() @@ -294,7 +337,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ limit: plan.limit, }) - return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) + return runInflight(inflight, key, () => + Promise.all([sessionReq, messagesReq]) + .then(() => {}) + .finally(() => { + perfLog("sync.done", { + sessionID: sessionID.slice(0, 12), + ms: Math.round(perfNow() - started), + skipMessages: plan.skipMessages, + limit: plan.limit, + }) + }), + ) }, async diff(sessionID: string) { const directory = sdk.directory diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index b9fc98864d4..574e63b92bb 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -88,7 +88,6 @@ type StageConfig = { type TimelineStageInput = { sessionKey: () => string - sessionID: () => string | undefined turnStart: () => number messages: () => UserMessage[] config: StageConfig @@ -102,12 +101,6 @@ type TimelineStageInput = { * new messages render immediately. */ function createTimelineStaging(input: TimelineStageInput) { - const stageView = createMemo(() => { - const list = input.messages() - const total = list.length - return { list, total } - }) - const [state, setState] = createStore({ activeSession: "", completedSession: "", @@ -115,7 +108,7 @@ function createTimelineStaging(input: TimelineStageInput) { }) const stagedCount = createMemo(() => { - const { total } = stageView() + const total = input.messages().length if (state.completedSession === input.sessionKey()) return total const init = Math.min(total, input.config.init) if (state.count <= init) return init @@ -124,7 +117,7 @@ function createTimelineStaging(input: TimelineStageInput) { }) const stagedUserMessages = createMemo(() => { - const list = stageView().list + const list = input.messages() const count = stagedCount() if (count >= list.length) return list return list.slice(Math.max(0, list.length - count)) @@ -140,11 +133,11 @@ function createTimelineStaging(input: TimelineStageInput) { createEffect( on( () => [input.sessionKey(), input.turnStart() > 0] as const, - ([sessionKey, hasMessages]) => { + ([sessionKey, isWindowed]) => { cancel() - const { total } = stageView() + const total = input.messages().length const shouldStage = - hasMessages && + isWindowed && total > input.config.init && state.completedSession !== sessionKey && state.activeSession !== sessionKey @@ -161,7 +154,7 @@ function createTimelineStaging(input: TimelineStageInput) { frame = undefined return } - const currentTotal = stageView().total + const currentTotal = input.messages().length count = Math.min(currentTotal, count + input.config.batch) startTransition(() => setState("count", count)) if (count >= currentTotal) { @@ -233,7 +226,6 @@ export function MessageTimeline(props: { const stageCfg = { init: 1, batch: 1 } const staging = createTimelineStaging({ sessionKey, - sessionID, turnStart: () => props.turnStart, messages: () => props.renderedUserMessages, config: stageCfg, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index d9fe5710b1d..e8db405fddd 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -519,7 +519,15 @@ export namespace Session { sessionID: Identifier.schema("session"), limit: z.number().optional(), }), - async (input) => MessageV2.list(input), + async (input) => { + const result = [] as MessageV2.WithParts[] + for await (const msg of MessageV2.stream(input.sessionID)) { + if (input.limit && result.length >= input.limit) break + result.push(msg) + } + result.reverse() + return result + }, ) export function* list(input?: { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 70478e43b7f..178751a2227 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -713,40 +713,6 @@ export namespace MessageV2 { ) } - type MessageRow = typeof MessageTable.$inferSelect - type PartRow = typeof PartTable.$inferSelect - - function fetchParts(ids: string[]): PartRow[] { - if (ids.length === 0) return [] - return Database.use((db) => - db - .select() - .from(PartTable) - .where(inArray(PartTable.message_id, ids)) - .orderBy(PartTable.message_id, PartTable.id) - .all(), - ) - } - - function assembleMessages(messageRows: MessageRow[], partRows: PartRow[]): WithParts[] { - const partsByMessage = new Map() - for (const row of partRows) { - const part = { - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - } as MessageV2.Part - const arr = partsByMessage.get(row.message_id) - if (arr) arr.push(part) - else partsByMessage.set(row.message_id, [part]) - } - return messageRows.map((row) => ({ - info: { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info, - parts: partsByMessage.get(row.id) ?? [], - })) - } - export const stream = fn(Identifier.schema("session"), async function* (sessionID) { const size = 50 let offset = 0 @@ -764,9 +730,35 @@ export namespace MessageV2 { if (rows.length === 0) break const ids = rows.map((row) => row.id) - const assembled = assembleMessages(rows, fetchParts(ids)) - for (const msg of assembled) { - yield msg + const partsByMessage = new Map() + if (ids.length > 0) { + const partRows = Database.use((db) => + db + .select() + .from(PartTable) + .where(inArray(PartTable.message_id, ids)) + .orderBy(PartTable.message_id, PartTable.id) + .all(), + ) + for (const row of partRows) { + const part = { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + const list = partsByMessage.get(row.message_id) + if (list) list.push(part) + else partsByMessage.set(row.message_id, [part]) + } + } + + for (const row of rows) { + const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info + yield { + info, + parts: partsByMessage.get(row.id) ?? [], + } } offset += rows.length @@ -774,37 +766,6 @@ export namespace MessageV2 { } }) - export const list = fn( - z.object({ - sessionID: Identifier.schema("session"), - limit: z.number().optional(), - }), - async (input) => { - // Fetch newest messages first (DESC), then reverse for chronological order - const messageRows = Database.use((db) => - input.limit - ? db - .select() - .from(MessageTable) - .where(eq(MessageTable.session_id, input.sessionID)) - .orderBy(desc(MessageTable.time_created)) - .limit(input.limit) - .all() - : db - .select() - .from(MessageTable) - .where(eq(MessageTable.session_id, input.sessionID)) - .orderBy(desc(MessageTable.time_created)) - .all(), - ) - if (messageRows.length === 0) return [] - messageRows.reverse() - - const ids = messageRows.map((row) => row.id) - return assembleMessages(messageRows, fetchParts(ids)) - }, - ) - export const parts = fn(Identifier.schema("message"), async (message_id) => { const rows = Database.use((db) => db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), From eb27b6ada2e384df7f502ec7489a85ef3fa3c638 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 1 Mar 2026 12:09:50 -0500 Subject: [PATCH 07/10] perf(session): simplify history loading and remove perf probes Use one page size for initial and incremental message fetches to reduce sync policy complexity, and refine history window loading paths for top-scroll and button reveal behavior. Remove temporary session perf logging and slightly increase timeline staging batch size for smoother catch-up rendering. --- packages/app/src/context/sync.tsx | 70 ++--------------- packages/app/src/pages/session.tsx | 77 +++++++++++-------- .../src/pages/session/message-timeline.tsx | 2 +- 3 files changed, 55 insertions(+), 94 deletions(-) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index bdcd65ed23c..827bf801b74 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -24,41 +24,35 @@ function runInflight(map: Map>, key: string, task: () => P const keyFor = (directory: string, id: string) => `${directory}\n${id}` const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -const perfEnabled = import.meta.env.DEV -const perfNow = () => (typeof performance === "undefined" ? Date.now() : performance.now()) -const perfLog = (event: string, data: Record) => { - if (!perfEnabled) return - console.log(`[session-perf] ${event}`, data) -} type SessionSyncPlanInput = { hasSession: boolean hasMessages: boolean hydrated: boolean limit: number | undefined - messageInitialSize: number messagePageSize: number } /** * Computes fetch policy for `session.sync`. * - * A hydrated session can skip re-fetching messages. Otherwise we bootstrap - * with the initial fetch size and let pagination grow from there. + * Hydrated sessions can skip message re-fetching. Non-hydrated sessions + * bootstrap with the default page size. */ function sessionSyncPlan(input: SessionSyncPlanInput) { + const limit = input.limit ?? input.messagePageSize if (input.hasSession && input.hasMessages && input.hydrated) { return { done: true, skipMessages: true, - limit: input.limit ?? input.messagePageSize, + limit, } } return { done: false, skipMessages: input.hasMessages && input.hydrated, - limit: input.hydrated ? (input.limit ?? input.messagePageSize) : input.messageInitialSize, + limit, } } @@ -141,7 +135,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messageInitialSize = 100 const messagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() @@ -183,7 +176,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(input.directory, input.sessionID) if (meta.loading[key]) return - const started = perfNow() setMeta("loading", key, true) await fetchMessages(input) .then((next) => { @@ -195,24 +187,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setMeta("limit", key, input.limit) setMeta("complete", key, next.complete) }) - const userCount = next.session.filter((m) => m.role === "user").length - perfLog("messages", { - sessionID: input.sessionID.slice(0, 12), - limit: input.limit, - fetched: next.session.length, - user: userCount, - complete: next.complete, - ms: Math.round(perfNow() - started), - }) - }) - .catch((error) => { - perfLog("messages.error", { - sessionID: input.sessionID.slice(0, 12), - limit: input.limit, - ms: Math.round(perfNow() - started), - error: error instanceof Error ? error.message : "unknown", - }) - throw error }) .finally(() => { setMeta("loading", key, false) @@ -279,7 +253,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(directory, sessionID) const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found - const started = perfNow() const hasMessages = store.message[sessionID] !== undefined const hydrated = meta.limit[key] !== undefined const plan = sessionSyncPlan({ @@ -287,27 +260,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ hasMessages, hydrated, limit: meta.limit[key], - messageInitialSize, messagePageSize, }) - if (plan.done) { - perfLog("sync.skip", { - sessionID: sessionID.slice(0, 12), - hasSession, - hasMessages, - hydrated, - }) - return - } - - perfLog("sync.start", { - sessionID: sessionID.slice(0, 12), - hasSession, - hasMessages, - hydrated, - skipMessages: plan.skipMessages, - limit: plan.limit, - }) + if (plan.done) return const sessionReq = hasSession ? Promise.resolve() @@ -337,18 +292,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ limit: plan.limit, }) - return runInflight(inflight, key, () => - Promise.all([sessionReq, messagesReq]) - .then(() => {}) - .finally(() => { - perfLog("sync.done", { - sessionID: sessionID.slice(0, 12), - ms: Math.round(perfNow() - started), - skipMessages: plan.skipMessages, - limit: plan.limit, - }) - }), - ) + return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) }, async diff(sessionID: string) { const directory = sdk.directory diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b602c36bf13..ec02137333a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -32,8 +32,6 @@ 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" -type LoadEarlierSource = "scroll" | "button" | "buffer" - type SessionHistoryWindowInput = { sessionID: () => string | undefined messagesReady: () => boolean @@ -126,54 +124,68 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { preserveScroll(() => setTurnStart(nextStart)) } - const loadEarlier = async (opts?: { - source?: LoadEarlierSource - revealCached?: boolean - revealAfterLoad?: boolean - }) => { - const source = opts?.source ?? "scroll" - const revealCached = opts?.revealCached ?? false - const revealAfterLoad = opts?.revealAfterLoad ?? true + /** 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 - const beforeRendered = revealCached || start <= 0 ? beforeVisible : renderedUserMessages().length - const expectedStart = revealCached && start > 0 ? 0 : start - if (revealCached && start > 0) { - setTurnStart(0) - } + + if (start > 0) setTurnStart(0) if (!input.historyMore() || input.historyLoading()) return - if (source === "buffer") { + 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 (source === "buffer") { - const noGrowth = growth > 0 ? 0 : state.prefetchNoGrowth + 1 - setState("prefetchNoGrowth", noGrowth) - } - if (source !== "buffer" && growth > 0 && state.prefetchNoGrowth) { + + if (opts?.prefetch) { + setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1) + } else if (growth > 0 && state.prefetchNoGrowth) { setState("prefetchNoGrowth", 0) } - if (growth <= 0) return - const currentStart = turnStart() - if (currentStart !== expectedStart) return + if (growth <= 0) return + if (turnStart() !== start) return + const reveal = !opts?.prefetch const currentRendered = renderedUserMessages().length const base = Math.max(beforeRendered, currentRendered) - const target = revealAfterLoad ? Math.min(afterVisible, base + turnBatch) : base + const target = reveal ? Math.min(afterVisible, base + turnBatch) : base const nextStart = Math.max(0, afterVisible - target) preserveScroll(() => setTurnStart(nextStart)) } @@ -187,13 +199,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { const start = turnStart() if (start > 0) { if (start <= turnPrefetchBuffer) { - void loadEarlier({ source: "buffer", revealAfterLoad: false }) + void fetchOlderMessages({ prefetch: true }) + } + // At the very top: reveal all cached turns at once instead of one batch + if (el.scrollTop < 1) { + preserveScroll(() => setTurnStart(0)) + } else { + backfillTurns() } - backfillTurns() return } - void loadEarlier({ source: "scroll" }) + void fetchOlderMessages() } createEffect( @@ -224,7 +241,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { turnStart, setTurnStart, renderedUserMessages, - loadEarlier, + loadAndReveal, onScrollerScroll, } } @@ -1204,7 +1221,7 @@ export default function Page() { historyMore={historyMore()} historyLoading={historyLoading()} onLoadEarlier={() => { - void historyWindow.loadEarlier({ source: "button", revealCached: true }) + void historyWindow.loadAndReveal() }} renderedUserMessages={historyWindow.renderedUserMessages()} anchor={anchor} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 574e63b92bb..5cc1add4bff 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -223,7 +223,7 @@ export function MessageTimeline(props: { const titleValue = createMemo(() => info()?.title) const parentID = createMemo(() => info()?.parentID) const showHeader = createMemo(() => !!(titleValue() || parentID())) - const stageCfg = { init: 1, batch: 1 } + const stageCfg = { init: 1, batch: 3 } const staging = createTimelineStaging({ sessionKey, turnStart: () => props.turnStart, From e46f6811de56c310299d2883f76c62d38785d362 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 1 Mar 2026 12:14:42 -0500 Subject: [PATCH 08/10] refactor(session): simplify sync policy and clean up history window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove sessionSyncPlan — always re-fetch messages on sync since the cache skip had no measurable impact. Extract initialTurnStart helper, split loadEarlier into loadAndReveal + fetchOlderMessages, bump staging batch to 3. --- packages/app/src/context/sync.tsx | 66 ++++++------------------------ packages/app/src/pages/session.tsx | 18 +++----- 2 files changed, 18 insertions(+), 66 deletions(-) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 827bf801b74..1ba6d42375e 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -25,37 +25,6 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}` const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -type SessionSyncPlanInput = { - hasSession: boolean - hasMessages: boolean - hydrated: boolean - limit: number | undefined - messagePageSize: number -} - -/** - * Computes fetch policy for `session.sync`. - * - * Hydrated sessions can skip message re-fetching. Non-hydrated sessions - * bootstrap with the default page size. - */ -function sessionSyncPlan(input: SessionSyncPlanInput) { - const limit = input.limit ?? input.messagePageSize - if (input.hasSession && input.hasMessages && input.hydrated) { - return { - done: true, - skipMessages: true, - limit, - } - } - - return { - done: false, - skipMessages: input.hasMessages && input.hydrated, - limit, - } -} - type OptimisticStore = { message: Record part: Record @@ -253,16 +222,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const key = keyFor(directory, sessionID) const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found - const hasMessages = store.message[sessionID] !== undefined - const hydrated = meta.limit[key] !== undefined - const plan = sessionSyncPlan({ - hasSession, - hasMessages, - hydrated, - limit: meta.limit[key], - messagePageSize, - }) - if (plan.done) return + const limit = meta.limit[key] ?? messagePageSize const sessionReq = hasSession ? Promise.resolve() @@ -282,15 +242,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) }) - const messagesReq = plan.skipMessages - ? Promise.resolve() - : loadMessages({ - directory, - client, - setStore, - sessionID, - limit: plan.limit, - }) + const messagesReq = loadMessages({ + directory, + client, + setStore, + sessionID, + limit, + }) return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) }, @@ -337,10 +295,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ more(sessionID: string) { const store = current()[0] const key = keyFor(sdk.directory, sessionID) - const hasMessages = store.message[sessionID] !== undefined - const hasLimit = meta.limit[key] !== undefined - const complete = meta.complete[key] ?? false - return hasMessages && hasLimit && !complete + if (store.message[sessionID] === undefined) return false + if (meta.limit[key] === undefined) return false + if (meta.complete[key]) return false + return true }, loading(sessionID: string) { const key = keyFor(sdk.directory, sessionID) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index ec02137333a..dcfb9c0dcf1 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -64,13 +64,15 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { 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 len > turnInit ? len - turnInit : 0 + if (state.turnID !== id) return initialTurnStart(len) if (state.turnStart <= 0) return 0 - if (state.turnStart >= len) return Math.max(0, len - turnInit) + if (state.turnStart >= len) return initialTurnStart(len) return state.turnStart }) @@ -201,12 +203,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { if (start <= turnPrefetchBuffer) { void fetchOlderMessages({ prefetch: true }) } - // At the very top: reveal all cached turns at once instead of one batch - if (el.scrollTop < 1) { - preserveScroll(() => setTurnStart(0)) - } else { - backfillTurns() - } + backfillTurns() return } @@ -228,10 +225,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { () => [input.sessionID(), input.messagesReady()] as const, ([id, ready]) => { if (!id || !ready) return - - const len = input.visibleUserMessages().length - const start = len > turnInit ? len - turnInit : 0 - setTurnStart(start) + setTurnStart(initialTurnStart(input.visibleUserMessages().length)) }, { defer: true }, ), From 759f1e8d6d50f262474203c5aceb421b8efdc3eb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 1 Mar 2026 12:28:30 -0500 Subject: [PATCH 09/10] fix(session): hide load-earlier during background sync Only show the load-earlier control when older history is actually available or the timeline is windowed. This avoids briefly showing a misleading loading state when revisiting short sessions. --- packages/app/src/pages/session.tsx | 9 ++++----- packages/app/src/pages/session/message-timeline.tsx | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index dcfb9c0dcf1..aadf18f13e1 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -32,6 +32,8 @@ 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 @@ -86,7 +88,6 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { setState({ turnID: id, turnStart: next }) } - const emptyUserMessages: UserMessage[] = [] const renderedUserMessages = createMemo( () => { const msgs = input.visibleUserMessages() @@ -386,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, @@ -497,9 +497,8 @@ export default function Page() { createEffect( on( - () => `${sdk.directory}\n${params.id ?? ""}`, - (k) => { - const [, id] = k.split("\n") + [() => sdk.directory, () => params.id] as const, + ([, id]) => { if (!id) return untrack(() => { void sync.session.sync(id) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 5cc1add4bff..b0ae170e4f1 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -636,7 +636,7 @@ export function MessageTimeline(props: { "mt-0": !props.centered, }} > - 0 || props.historyMore || props.historyLoading}> + 0 || props.historyMore}>