summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-01 14:17:04 -0500
committerGitHub <[email protected]>2026-03-01 13:17:04 -0600
commitc0483affa68032734076bdc9e0d73657eb01d6f0 (patch)
tree660b4656aa19504fa0e4e9a041515aa9b3612872
parentae0f69e1faa960aa2ab8f6f380b59a9fb9ab1bee (diff)
downloadopencode-c0483affa68032734076bdc9e0d73657eb01d6f0.tar.gz
opencode-c0483affa68032734076bdc9e0d73657eb01d6f0.zip
perf(session): faster session switching via windowed rendering and staged timeline (#15474)
-rw-r--r--packages/app/src/context/sync.tsx59
-rw-r--r--packages/app/src/pages/session.tsx355
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx130
-rw-r--r--packages/app/src/pages/session/use-session-hash-scroll.ts2
4 files changed, 374 insertions, 172 deletions
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 60888b1a6..ed54751c3 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -43,12 +43,11 @@ type OptimisticRemoveInput = {
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
- if (!messages) {
- draft.message[input.sessionID] = [input.message]
- }
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
+ } else {
+ draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
@@ -105,7 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
- const messagePageSize = 400
+ const messagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -122,20 +121,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined
}
- const limitFor = (count: number) => {
- if (count <= messagePageSize) return messagePageSize
- return Math.ceil(count / messagePageSize) * messagePageSize
- }
-
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
- const session = items
- .map((x) => x.info)
- .filter((m) => !!m?.id)
- .sort((a, b) => cmp(a.id, b.id))
+ const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
return {
session,
@@ -159,8 +150,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.then((next) => {
batch(() => {
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
- for (const message of next.part) {
- input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
+ for (const p of next.part) {
+ input.setStore("part", p.id, p.part)
}
setMeta("limit", key, input.limit)
setMeta("complete", key, next.complete)
@@ -229,17 +220,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
- const hasSession = (() => {
- const match = Binary.search(store.session, sessionID, (s) => s.id)
- return match.found
- })()
+ const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
- const hasMessages = store.message[sessionID] !== undefined
- const hydrated = meta.limit[key] !== undefined
- if (hasSession && hasMessages && hydrated) return
-
- const count = store.message[sessionID]?.length ?? 0
- const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
+ const limit = meta.limit[key] ?? messagePageSize
const sessionReq = hasSession
? Promise.resolve()
@@ -259,16 +242,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
)
})
- const messagesReq =
- hasMessages && hydrated
- ? Promise.resolve()
- : loadMessages({
- directory,
- client,
- setStore,
- sessionID,
- limit,
- })
+ const messagesReq = loadMessages({
+ directory,
+ client,
+ setStore,
+ sessionID,
+ limit,
+ })
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
},
@@ -290,14 +270,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const existing = store.todo[sessionID]
+ const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
- if (globalSync.data.session_todo[sessionID] === undefined) {
+ if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
return
}
- const cached = globalSync.data.session_todo[sessionID]
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
@@ -324,11 +304,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(sdk.directory, sessionID)
return meta.loading[key] ?? false
},
- async loadMore(sessionID: string, count = messagePageSize) {
+ async loadMore(sessionID: string, count?: number) {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
+ const step = count ?? messagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
@@ -338,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
client,
setStore,
sessionID,
- limit: currentLimit + count,
+ limit: currentLimit + step,
})
},
},
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 5ef68cc5c..aadf18f13 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1,4 +1,4 @@
-import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount } from "solid-js"
+import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount, untrack } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocal } from "@/context/local"
@@ -32,6 +32,215 @@ import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
+const emptyUserMessages: UserMessage[] = []
+
+type SessionHistoryWindowInput = {
+ sessionID: () => string | undefined
+ messagesReady: () => boolean
+ visibleUserMessages: () => UserMessage[]
+ historyMore: () => boolean
+ historyLoading: () => boolean
+ loadMore: (sessionID: string) => Promise<void>
+ userScrolled: () => boolean
+ scroller: () => HTMLDivElement | undefined
+}
+
+/**
+ * Maintains the rendered history window for a session timeline.
+ *
+ * It keeps initial paint bounded to recent turns, reveals cached turns in
+ * small batches while scrolling upward, and prefetches older history near top.
+ */
+function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
+ const turnInit = 10
+ const turnBatch = 8
+ const turnScrollThreshold = 200
+ const turnPrefetchBuffer = 16
+ const prefetchCooldownMs = 400
+ const prefetchNoGrowthLimit = 2
+
+ const [state, setState] = createStore({
+ turnID: undefined as string | undefined,
+ turnStart: 0,
+ prefetchUntil: 0,
+ prefetchNoGrowth: 0,
+ })
+
+ const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
+
+ const turnStart = createMemo(() => {
+ const id = input.sessionID()
+ const len = input.visibleUserMessages().length
+ if (!id || len <= 0) return 0
+ if (state.turnID !== id) return initialTurnStart(len)
+ if (state.turnStart <= 0) return 0
+ if (state.turnStart >= len) return initialTurnStart(len)
+ return state.turnStart
+ })
+
+ const setTurnStart = (start: number) => {
+ const id = input.sessionID()
+ const next = start > 0 ? start : 0
+ if (!id) {
+ setState({ turnID: undefined, turnStart: next })
+ return
+ }
+ setState({ turnID: id, turnStart: next })
+ }
+
+ const renderedUserMessages = createMemo(
+ () => {
+ const msgs = input.visibleUserMessages()
+ const start = turnStart()
+ if (start <= 0) return msgs
+ return msgs.slice(start)
+ },
+ emptyUserMessages,
+ {
+ equals: same,
+ },
+ )
+
+ const preserveScroll = (fn: () => void) => {
+ const el = input.scroller()
+ if (!el) {
+ fn()
+ return
+ }
+ const beforeTop = el.scrollTop
+ const beforeHeight = el.scrollHeight
+ fn()
+ requestAnimationFrame(() => {
+ const delta = el.scrollHeight - beforeHeight
+ if (!delta) return
+ el.scrollTop = beforeTop + delta
+ })
+ }
+
+ const backfillTurns = () => {
+ const start = turnStart()
+ if (start <= 0) return
+
+ const next = start - turnBatch
+ const nextStart = next > 0 ? next : 0
+
+ preserveScroll(() => setTurnStart(nextStart))
+ }
+
+ /** Button path: reveal all cached turns, fetch older history, reveal one batch. */
+ const loadAndReveal = async () => {
+ const id = input.sessionID()
+ if (!id) return
+
+ const start = turnStart()
+ const beforeVisible = input.visibleUserMessages().length
+
+ if (start > 0) setTurnStart(0)
+
+ if (!input.historyMore() || input.historyLoading()) return
+
+ await input.loadMore(id)
+ if (input.sessionID() !== id) return
+
+ const afterVisible = input.visibleUserMessages().length
+ const growth = afterVisible - beforeVisible
+ if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
+ if (growth <= 0) return
+ if (turnStart() !== 0) return
+
+ const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
+ const nextStart = Math.max(0, afterVisible - target)
+ preserveScroll(() => setTurnStart(nextStart))
+ }
+
+ /** Scroll/prefetch path: fetch older history from server. */
+ const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
+ const id = input.sessionID()
+ if (!id) return
+ if (!input.historyMore() || input.historyLoading()) return
+
+ if (opts?.prefetch) {
+ const now = Date.now()
+ if (state.prefetchUntil > now) return
+ if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
+ setState("prefetchUntil", now + prefetchCooldownMs)
+ }
+
+ const start = turnStart()
+ const beforeVisible = input.visibleUserMessages().length
+ const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
+
+ await input.loadMore(id)
+ if (input.sessionID() !== id) return
+
+ const afterVisible = input.visibleUserMessages().length
+ const growth = afterVisible - beforeVisible
+
+ if (opts?.prefetch) {
+ setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
+ } else if (growth > 0 && state.prefetchNoGrowth) {
+ setState("prefetchNoGrowth", 0)
+ }
+
+ if (growth <= 0) return
+ if (turnStart() !== start) return
+
+ const reveal = !opts?.prefetch
+ const currentRendered = renderedUserMessages().length
+ const base = Math.max(beforeRendered, currentRendered)
+ const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
+ const nextStart = Math.max(0, afterVisible - target)
+ preserveScroll(() => setTurnStart(nextStart))
+ }
+
+ const onScrollerScroll = () => {
+ if (!input.userScrolled()) return
+ const el = input.scroller()
+ if (!el) return
+ if (el.scrollTop >= turnScrollThreshold) return
+
+ const start = turnStart()
+ if (start > 0) {
+ if (start <= turnPrefetchBuffer) {
+ void fetchOlderMessages({ prefetch: true })
+ }
+ backfillTurns()
+ return
+ }
+
+ void fetchOlderMessages()
+ }
+
+ createEffect(
+ on(
+ input.sessionID,
+ () => {
+ setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => [input.sessionID(), input.messagesReady()] as const,
+ ([id, ready]) => {
+ if (!id || !ready) return
+ setTurnStart(initialTurnStart(input.visibleUserMessages().length))
+ },
+ { defer: true },
+ ),
+ )
+
+ return {
+ turnStart,
+ setTurnStart,
+ renderedUserMessages,
+ loadAndReveal,
+ onScrollerScroll,
+ }
+}
+
export default function Page() {
const layout = useLayout()
const local = useLocal()
@@ -178,7 +387,6 @@ export default function Page() {
return sync.session.history.loading(id)
})
- const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
@@ -211,7 +419,6 @@ export default function Page() {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
- turnStart: 0,
mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn",
newSessionWorktree: "main",
@@ -220,20 +427,6 @@ export default function Page() {
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
- const renderedUserMessages = createMemo(
- () => {
- const msgs = visibleUserMessages()
- const start = store.turnStart
- if (start <= 0) return msgs
- if (start >= msgs.length) return emptyUserMessages
- return msgs.slice(start)
- },
- emptyUserMessages,
- {
- equals: same,
- },
- )
-
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
const project = sync.project
@@ -302,13 +495,18 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
- createEffect(() => {
- sdk.directory
- const id = params.id
- if (!id) return
- void sync.session.sync(id)
- void sync.session.todo(id)
- })
+ createEffect(
+ on(
+ [() => sdk.directory, () => params.id] as const,
+ ([, id]) => {
+ if (!id) return
+ untrack(() => {
+ void sync.session.sync(id)
+ void sync.session.todo(id)
+ })
+ },
+ ),
+ )
createEffect(
on(
@@ -894,88 +1092,16 @@ export default function Page() {
},
)
- const turnInit = 20
- const turnBatch = 20
- let turnHandle: number | undefined
- let turnIdle = false
-
- function cancelTurnBackfill() {
- const handle = turnHandle
- if (handle === undefined) return
- turnHandle = undefined
-
- if (turnIdle && window.cancelIdleCallback) {
- window.cancelIdleCallback(handle)
- return
- }
-
- clearTimeout(handle)
- }
-
- function scheduleTurnBackfill() {
- if (turnHandle !== undefined) return
- if (store.turnStart <= 0) return
-
- if (window.requestIdleCallback) {
- turnIdle = true
- turnHandle = window.requestIdleCallback(() => {
- turnHandle = undefined
- backfillTurns()
- })
- return
- }
-
- turnIdle = false
- turnHandle = window.setTimeout(() => {
- turnHandle = undefined
- backfillTurns()
- }, 0)
- }
-
- function backfillTurns() {
- const start = store.turnStart
- if (start <= 0) return
-
- const next = start - turnBatch
- const nextStart = next > 0 ? next : 0
-
- const el = scroller
- if (!el) {
- setStore("turnStart", nextStart)
- scheduleTurnBackfill()
- return
- }
-
- const beforeTop = el.scrollTop
- const beforeHeight = el.scrollHeight
-
- setStore("turnStart", nextStart)
-
- requestAnimationFrame(() => {
- const delta = el.scrollHeight - beforeHeight
- if (!delta) return
- el.scrollTop = beforeTop + delta
- })
-
- scheduleTurnBackfill()
- }
-
- createEffect(
- on(
- () => [params.id, messagesReady()] as const,
- ([id, ready]) => {
- cancelTurnBackfill()
- setStore("turnStart", 0)
- if (!id || !ready) return
-
- const len = visibleUserMessages().length
- const start = len > turnInit ? len - turnInit : 0
- setStore("turnStart", start)
- scheduleTurnBackfill()
- },
- { defer: true },
- ),
- )
+ const historyWindow = createSessionHistoryWindow({
+ sessionID: () => params.id,
+ messagesReady,
+ visibleUserMessages,
+ historyMore,
+ historyLoading,
+ loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
+ userScrolled: autoScroll.userScrolled,
+ scroller: () => scroller,
+ })
createResizeObserver(
() => promptDock,
@@ -1002,13 +1128,12 @@ export default function Page() {
sessionID: () => params.id,
messagesReady,
visibleUserMessages,
- turnStart: () => store.turnStart,
+ turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
setPendingMessage: (value) => setUi("pendingMessage", value),
setActiveMessage,
- setTurnStart: (value) => setStore("turnStart", value),
- scheduleTurnBackfill,
+ setTurnStart: historyWindow.setTurnStart,
autoScroll,
scroller: () => scroller,
anchor,
@@ -1021,7 +1146,6 @@ export default function Page() {
})
onCleanup(() => {
- cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown)
scrollSpy.destroy()
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
@@ -1076,6 +1200,7 @@ export default function Page() {
hasScrollGesture={hasScrollGesture}
isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll}
+ onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
centered={centered()}
setContentRef={(el) => {
@@ -1085,17 +1210,13 @@ export default function Page() {
const root = scroller
if (root) scheduleScrollState(root)
}}
- turnStart={store.turnStart}
- onRenderEarlier={() => setStore("turnStart", 0)}
+ turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
- const id = params.id
- if (!id) return
- setStore("turnStart", 0)
- sync.session.history.loadMore(id)
+ void historyWindow.loadAndReveal()
}}
- renderedUserMessages={renderedUserMessages()}
+ renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister}
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index d2de720a3..7c711989e 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -1,4 +1,4 @@
-import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "solid-js"
+import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
@@ -81,6 +81,103 @@ const markBoundaryGesture = (input: {
}
}
+type StageConfig = {
+ init: number
+ batch: number
+}
+
+type TimelineStageInput = {
+ sessionKey: () => string
+ turnStart: () => number
+ messages: () => UserMessage[]
+ config: StageConfig
+}
+
+/**
+ * Defer-mounts small timeline windows so revealing older turns does not
+ * block first paint with a large DOM mount.
+ *
+ * Once staging completes for a session it never re-stages — backfill and
+ * new messages render immediately.
+ */
+function createTimelineStaging(input: TimelineStageInput) {
+ const [state, setState] = createStore({
+ activeSession: "",
+ completedSession: "",
+ count: 0,
+ })
+
+ const stagedCount = createMemo(() => {
+ const total = input.messages().length
+ if (input.turnStart() <= 0) return total
+ if (state.completedSession === input.sessionKey()) return total
+ const init = Math.min(total, input.config.init)
+ if (state.count <= init) return init
+ if (state.count >= total) return total
+ return state.count
+ })
+
+ const stagedUserMessages = createMemo(() => {
+ const list = input.messages()
+ const count = stagedCount()
+ if (count >= list.length) return list
+ return list.slice(Math.max(0, list.length - count))
+ })
+
+ let frame: number | undefined
+ const cancel = () => {
+ if (frame === undefined) return
+ cancelAnimationFrame(frame)
+ frame = undefined
+ }
+
+ createEffect(
+ on(
+ () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
+ ([sessionKey, isWindowed, total]) => {
+ cancel()
+ const shouldStage =
+ isWindowed &&
+ total > input.config.init &&
+ state.completedSession !== sessionKey &&
+ state.activeSession !== sessionKey
+ if (!shouldStage) {
+ setState({ activeSession: "", count: total })
+ return
+ }
+
+ let count = Math.min(total, input.config.init)
+ setState({ activeSession: sessionKey, count })
+
+ const step = () => {
+ if (input.sessionKey() !== sessionKey) {
+ frame = undefined
+ return
+ }
+ const currentTotal = input.messages().length
+ count = Math.min(currentTotal, count + input.config.batch)
+ startTransition(() => setState("count", count))
+ if (count >= currentTotal) {
+ setState({ completedSession: sessionKey, activeSession: "" })
+ frame = undefined
+ return
+ }
+ frame = requestAnimationFrame(step)
+ }
+ frame = requestAnimationFrame(step)
+ },
+ ),
+ )
+
+ const isStaging = createMemo(() => {
+ const key = input.sessionKey()
+ return state.activeSession === key && state.completedSession !== key
+ })
+
+ onCleanup(cancel)
+ return { messages: stagedUserMessages, isStaging }
+}
+
export function MessageTimeline(props: {
mobileChanges: boolean
mobileFallback: JSX.Element
@@ -93,11 +190,11 @@ export function MessageTimeline(props: {
hasScrollGesture: () => boolean
isDesktop: boolean
onScrollSpyScroll: () => void
+ onTurnBackfillScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
centered: boolean
setContentRef: (el: HTMLDivElement) => void
turnStart: number
- onRenderEarlier: () => void
historyMore: boolean
historyLoading: boolean
onLoadEarlier: () => void
@@ -126,6 +223,13 @@ export function MessageTimeline(props: {
const titleValue = createMemo(() => info()?.title)
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
+ const stageCfg = { init: 1, batch: 3 }
+ const staging = createTimelineStaging({
+ sessionKey,
+ turnStart: () => props.turnStart,
+ messages: () => props.renderedUserMessages,
+ config: stageCfg,
+ })
const [title, setTitle] = createStore({
draft: "",
@@ -342,8 +446,10 @@ export function MessageTimeline(props: {
<div
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
- "opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
- "opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
+ "opacity-100 translate-y-0 scale-100":
+ props.scroll.overflow && !props.scroll.bottom && !staging.isStaging(),
+ "opacity-0 translate-y-2 scale-95 pointer-events-none":
+ !props.scroll.overflow || props.scroll.bottom || staging.isStaging(),
}}
>
<button
@@ -392,6 +498,7 @@ export function MessageTimeline(props: {
}}
onScroll={(e) => {
props.onScheduleScrollState(e.currentTarget)
+ props.onTurnBackfillScroll()
if (!props.hasScrollGesture()) return
props.onAutoScrollHandleScroll()
props.onMarkScrollGesture(e.currentTarget)
@@ -529,14 +636,7 @@ export function MessageTimeline(props: {
"mt-0": !props.centered,
}}
>
- <Show when={props.turnStart > 0}>
- <div class="w-full flex justify-center">
- <Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
- {language.t("session.messages.renderEarlier")}
- </Button>
- </div>
- </Show>
- <Show when={props.historyMore}>
+ <Show when={props.turnStart > 0 || props.historyMore}>
<div class="w-full flex justify-center">
<Button
variant="ghost"
@@ -551,9 +651,10 @@ export function MessageTimeline(props: {
</Button>
</div>
</Show>
- <For each={props.renderedUserMessages}>
+ <For each={staging.messages()}>
{(message) => {
const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
+ const commentCount = createMemo(() => comments().length)
return (
<div
id={props.anchor(message.id)}
@@ -566,8 +667,9 @@ export function MessageTimeline(props: {
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
+ style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
>
- <Show when={comments().length > 0}>
+ <Show when={commentCount() > 0}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
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 b704e460b..235714588 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))