perf(session): faster session switching via windowed rendering and staged timeline (#15474)
This commit is contained in:
@@ -43,12 +43,11 @@ type OptimisticRemoveInput = {
|
|||||||
|
|
||||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||||
const messages = draft.message[input.sessionID]
|
const messages = draft.message[input.sessionID]
|
||||||
if (!messages) {
|
|
||||||
draft.message[input.sessionID] = [input.message]
|
|
||||||
}
|
|
||||||
if (messages) {
|
if (messages) {
|
||||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||||
messages.splice(result.index, 0, input.message)
|
messages.splice(result.index, 0, input.message)
|
||||||
|
} else {
|
||||||
|
draft.message[input.sessionID] = [input.message]
|
||||||
}
|
}
|
||||||
draft.part[input.message.id] = sortParts(input.parts)
|
draft.part[input.message.id] = sortParts(input.parts)
|
||||||
}
|
}
|
||||||
@@ -105,7 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return globalSync.child(directory)
|
return globalSync.child(directory)
|
||||||
}
|
}
|
||||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||||
const messagePageSize = 400
|
const messagePageSize = 200
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightDiff = new Map<string, Promise<void>>()
|
const inflightDiff = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = 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
|
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 fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||||
const messages = await retry(() =>
|
const messages = await retry(() =>
|
||||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
||||||
)
|
)
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
const session = items
|
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
|
||||||
.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) }))
|
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
@@ -159,8 +150,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
.then((next) => {
|
.then((next) => {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||||
for (const message of next.part) {
|
for (const p of next.part) {
|
||||||
input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
|
input.setStore("part", p.id, p.part)
|
||||||
}
|
}
|
||||||
setMeta("limit", key, input.limit)
|
setMeta("limit", key, input.limit)
|
||||||
setMeta("complete", key, next.complete)
|
setMeta("complete", key, next.complete)
|
||||||
@@ -229,17 +220,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
const hasSession = (() => {
|
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
|
||||||
return match.found
|
|
||||||
})()
|
|
||||||
|
|
||||||
const hasMessages = store.message[sessionID] !== undefined
|
const limit = meta.limit[key] ?? messagePageSize
|
||||||
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 sessionReq = hasSession
|
const sessionReq = hasSession
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
@@ -259,16 +242,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const messagesReq =
|
const messagesReq = loadMessages({
|
||||||
hasMessages && hydrated
|
directory,
|
||||||
? Promise.resolve()
|
client,
|
||||||
: loadMessages({
|
setStore,
|
||||||
directory,
|
sessionID,
|
||||||
client,
|
limit,
|
||||||
setStore,
|
})
|
||||||
sessionID,
|
|
||||||
limit,
|
|
||||||
})
|
|
||||||
|
|
||||||
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
|
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 client = sdk.client
|
||||||
const [store, setStore] = globalSync.child(directory)
|
const [store, setStore] = globalSync.child(directory)
|
||||||
const existing = store.todo[sessionID]
|
const existing = store.todo[sessionID]
|
||||||
|
const cached = globalSync.data.session_todo[sessionID]
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
if (globalSync.data.session_todo[sessionID] === undefined) {
|
if (cached === undefined) {
|
||||||
globalSync.todo.set(sessionID, existing)
|
globalSync.todo.set(sessionID, existing)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = globalSync.data.session_todo[sessionID]
|
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
|
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
|
||||||
}
|
}
|
||||||
@@ -324,11 +304,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
const key = keyFor(sdk.directory, sessionID)
|
const key = keyFor(sdk.directory, sessionID)
|
||||||
return meta.loading[key] ?? false
|
return meta.loading[key] ?? false
|
||||||
},
|
},
|
||||||
async loadMore(sessionID: string, count = messagePageSize) {
|
async loadMore(sessionID: string, count?: number) {
|
||||||
const directory = sdk.directory
|
const directory = sdk.directory
|
||||||
const client = sdk.client
|
const client = sdk.client
|
||||||
const [, setStore] = globalSync.child(directory)
|
const [, setStore] = globalSync.child(directory)
|
||||||
const key = keyFor(directory, sessionID)
|
const key = keyFor(directory, sessionID)
|
||||||
|
const step = count ?? messagePageSize
|
||||||
if (meta.loading[key]) return
|
if (meta.loading[key]) return
|
||||||
if (meta.complete[key]) return
|
if (meta.complete[key]) return
|
||||||
|
|
||||||
@@ -338,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
client,
|
client,
|
||||||
setStore,
|
setStore,
|
||||||
sessionID,
|
sessionID,
|
||||||
limit: currentLimit + count,
|
limit: currentLimit + step,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+238
-117
@@ -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 { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { useLocal } from "@/context/local"
|
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 { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
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() {
|
export default function Page() {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
@@ -178,7 +387,6 @@ export default function Page() {
|
|||||||
return sync.session.history.loading(id)
|
return sync.session.history.loading(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const emptyUserMessages: UserMessage[] = []
|
|
||||||
const userMessages = createMemo(
|
const userMessages = createMemo(
|
||||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||||
emptyUserMessages,
|
emptyUserMessages,
|
||||||
@@ -211,7 +419,6 @@ export default function Page() {
|
|||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
turnStart: 0,
|
|
||||||
mobileTab: "session" as "session" | "changes",
|
mobileTab: "session" as "session" | "changes",
|
||||||
changes: "session" as "session" | "turn",
|
changes: "session" as "session" | "turn",
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
@@ -220,20 +427,6 @@ export default function Page() {
|
|||||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
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(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.newSessionWorktree === "create") return "create"
|
if (store.newSessionWorktree === "create") return "create"
|
||||||
const project = sync.project
|
const project = sync.project
|
||||||
@@ -302,13 +495,18 @@ export default function Page() {
|
|||||||
|
|
||||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
sdk.directory
|
on(
|
||||||
const id = params.id
|
[() => sdk.directory, () => params.id] as const,
|
||||||
if (!id) return
|
([, id]) => {
|
||||||
void sync.session.sync(id)
|
if (!id) return
|
||||||
void sync.session.todo(id)
|
untrack(() => {
|
||||||
})
|
void sync.session.sync(id)
|
||||||
|
void sync.session.todo(id)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
@@ -894,88 +1092,16 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const turnInit = 20
|
const historyWindow = createSessionHistoryWindow({
|
||||||
const turnBatch = 20
|
sessionID: () => params.id,
|
||||||
let turnHandle: number | undefined
|
messagesReady,
|
||||||
let turnIdle = false
|
visibleUserMessages,
|
||||||
|
historyMore,
|
||||||
function cancelTurnBackfill() {
|
historyLoading,
|
||||||
const handle = turnHandle
|
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||||
if (handle === undefined) return
|
userScrolled: autoScroll.userScrolled,
|
||||||
turnHandle = undefined
|
scroller: () => scroller,
|
||||||
|
})
|
||||||
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 },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createResizeObserver(
|
createResizeObserver(
|
||||||
() => promptDock,
|
() => promptDock,
|
||||||
@@ -1002,13 +1128,12 @@ export default function Page() {
|
|||||||
sessionID: () => params.id,
|
sessionID: () => params.id,
|
||||||
messagesReady,
|
messagesReady,
|
||||||
visibleUserMessages,
|
visibleUserMessages,
|
||||||
turnStart: () => store.turnStart,
|
turnStart: historyWindow.turnStart,
|
||||||
currentMessageId: () => store.messageId,
|
currentMessageId: () => store.messageId,
|
||||||
pendingMessage: () => ui.pendingMessage,
|
pendingMessage: () => ui.pendingMessage,
|
||||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||||
setActiveMessage,
|
setActiveMessage,
|
||||||
setTurnStart: (value) => setStore("turnStart", value),
|
setTurnStart: historyWindow.setTurnStart,
|
||||||
scheduleTurnBackfill,
|
|
||||||
autoScroll,
|
autoScroll,
|
||||||
scroller: () => scroller,
|
scroller: () => scroller,
|
||||||
anchor,
|
anchor,
|
||||||
@@ -1021,7 +1146,6 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
cancelTurnBackfill()
|
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
scrollSpy.destroy()
|
scrollSpy.destroy()
|
||||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||||
@@ -1076,6 +1200,7 @@ export default function Page() {
|
|||||||
hasScrollGesture={hasScrollGesture}
|
hasScrollGesture={hasScrollGesture}
|
||||||
isDesktop={isDesktop()}
|
isDesktop={isDesktop()}
|
||||||
onScrollSpyScroll={scrollSpy.onScroll}
|
onScrollSpyScroll={scrollSpy.onScroll}
|
||||||
|
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||||
centered={centered()}
|
centered={centered()}
|
||||||
setContentRef={(el) => {
|
setContentRef={(el) => {
|
||||||
@@ -1085,17 +1210,13 @@ export default function Page() {
|
|||||||
const root = scroller
|
const root = scroller
|
||||||
if (root) scheduleScrollState(root)
|
if (root) scheduleScrollState(root)
|
||||||
}}
|
}}
|
||||||
turnStart={store.turnStart}
|
turnStart={historyWindow.turnStart()}
|
||||||
onRenderEarlier={() => setStore("turnStart", 0)}
|
|
||||||
historyMore={historyMore()}
|
historyMore={historyMore()}
|
||||||
historyLoading={historyLoading()}
|
historyLoading={historyLoading()}
|
||||||
onLoadEarlier={() => {
|
onLoadEarlier={() => {
|
||||||
const id = params.id
|
void historyWindow.loadAndReveal()
|
||||||
if (!id) return
|
|
||||||
setStore("turnStart", 0)
|
|
||||||
sync.session.history.loadMore(id)
|
|
||||||
}}
|
}}
|
||||||
renderedUserMessages={renderedUserMessages()}
|
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||||
anchor={anchor}
|
anchor={anchor}
|
||||||
onRegisterMessage={scrollSpy.register}
|
onRegisterMessage={scrollSpy.register}
|
||||||
onUnregisterMessage={scrollSpy.unregister}
|
onUnregisterMessage={scrollSpy.unregister}
|
||||||
|
|||||||
@@ -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 { createStore, produce } from "solid-js/store"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
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: {
|
export function MessageTimeline(props: {
|
||||||
mobileChanges: boolean
|
mobileChanges: boolean
|
||||||
mobileFallback: JSX.Element
|
mobileFallback: JSX.Element
|
||||||
@@ -93,11 +190,11 @@ export function MessageTimeline(props: {
|
|||||||
hasScrollGesture: () => boolean
|
hasScrollGesture: () => boolean
|
||||||
isDesktop: boolean
|
isDesktop: boolean
|
||||||
onScrollSpyScroll: () => void
|
onScrollSpyScroll: () => void
|
||||||
|
onTurnBackfillScroll: () => void
|
||||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||||
centered: boolean
|
centered: boolean
|
||||||
setContentRef: (el: HTMLDivElement) => void
|
setContentRef: (el: HTMLDivElement) => void
|
||||||
turnStart: number
|
turnStart: number
|
||||||
onRenderEarlier: () => void
|
|
||||||
historyMore: boolean
|
historyMore: boolean
|
||||||
historyLoading: boolean
|
historyLoading: boolean
|
||||||
onLoadEarlier: () => void
|
onLoadEarlier: () => void
|
||||||
@@ -126,6 +223,13 @@ export function MessageTimeline(props: {
|
|||||||
const titleValue = createMemo(() => info()?.title)
|
const titleValue = createMemo(() => info()?.title)
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
const showHeader = createMemo(() => !!(titleValue() || 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({
|
const [title, setTitle] = createStore({
|
||||||
draft: "",
|
draft: "",
|
||||||
@@ -342,8 +446,10 @@ export function MessageTimeline(props: {
|
|||||||
<div
|
<div
|
||||||
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
|
"opacity-100 translate-y-0 scale-100":
|
||||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
|
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
|
<button
|
||||||
@@ -392,6 +498,7 @@ export function MessageTimeline(props: {
|
|||||||
}}
|
}}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
props.onScheduleScrollState(e.currentTarget)
|
props.onScheduleScrollState(e.currentTarget)
|
||||||
|
props.onTurnBackfillScroll()
|
||||||
if (!props.hasScrollGesture()) return
|
if (!props.hasScrollGesture()) return
|
||||||
props.onAutoScrollHandleScroll()
|
props.onAutoScrollHandleScroll()
|
||||||
props.onMarkScrollGesture(e.currentTarget)
|
props.onMarkScrollGesture(e.currentTarget)
|
||||||
@@ -529,14 +636,7 @@ export function MessageTimeline(props: {
|
|||||||
"mt-0": !props.centered,
|
"mt-0": !props.centered,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={props.turnStart > 0}>
|
<Show when={props.turnStart > 0 || props.historyMore}>
|
||||||
<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}>
|
|
||||||
<div class="w-full flex justify-center">
|
<div class="w-full flex justify-center">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -551,9 +651,10 @@ export function MessageTimeline(props: {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<For each={props.renderedUserMessages}>
|
<For each={staging.messages()}>
|
||||||
{(message) => {
|
{(message) => {
|
||||||
const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
|
const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
|
||||||
|
const commentCount = createMemo(() => comments().length)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={props.anchor(message.id)}
|
id={props.anchor(message.id)}
|
||||||
@@ -566,8 +667,9 @@ export function MessageTimeline(props: {
|
|||||||
"min-w-0 w-full max-w-full": true,
|
"min-w-0 w-full max-w-full": true,
|
||||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
"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="w-full px-4 md:px-5 pb-2">
|
||||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||||
<div class="flex w-max min-w-full justify-end gap-2">
|
<div class="flex w-max min-w-full justify-end gap-2">
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export const useSessionHashScroll = (input: {
|
|||||||
setPendingMessage: (value: string | undefined) => void
|
setPendingMessage: (value: string | undefined) => void
|
||||||
setActiveMessage: (message: UserMessage | undefined) => void
|
setActiveMessage: (message: UserMessage | undefined) => void
|
||||||
setTurnStart: (value: number) => void
|
setTurnStart: (value: number) => void
|
||||||
scheduleTurnBackfill: () => void
|
|
||||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||||
scroller: () => HTMLDivElement | undefined
|
scroller: () => HTMLDivElement | undefined
|
||||||
anchor: (id: string) => string
|
anchor: (id: string) => string
|
||||||
@@ -58,7 +57,6 @@ export const useSessionHashScroll = (input: {
|
|||||||
const index = messageIndex().get(message.id) ?? -1
|
const index = messageIndex().get(message.id) ?? -1
|
||||||
if (index !== -1 && index < input.turnStart()) {
|
if (index !== -1 && index < input.turnStart()) {
|
||||||
input.setTurnStart(index)
|
input.setTurnStart(index)
|
||||||
input.scheduleTurnBackfill()
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = document.getElementById(input.anchor(message.id))
|
const el = document.getElementById(input.anchor(message.id))
|
||||||
|
|||||||
Reference in New Issue
Block a user