feat(app): make session timelines much faster AND without flicker or scroll jumps (#32331)

This commit is contained in:
Luke Parker 2026-06-16 18:53:57 +02:00 committed by GitHub
commit 3b811bd019
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 2824 additions and 845 deletions

View file

@ -134,6 +134,26 @@ describe("applyGlobalEvent", () => {
})
describe("applyDirectoryEvent", () => {
test("initializes text delta accumulation from the current part text", () => {
const part = { ...textPart("part", "session", "message"), text: "existing" }
const [store, setStore] = createStore(baseState({ part: { message: [part] } }))
applyDirectoryEvent({
event: {
type: "message.part.delta",
properties: { messageID: "message", partID: "part", field: "text", delta: " appended" },
},
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.part_text_accum_delta.part).toBe("existing appended")
expect((store.part.message?.[0] as { text: string }).text).toBe("existing appended")
})
test("preserves a Home-specific retained session limit", () => {
const [store, setStore] = createStore(
baseState({

View file

@ -282,7 +282,13 @@ export function applyDirectoryEvent(input: {
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
input.setStore(
"part_text_accum_delta",
props.partID,
(existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta,
)
input.setStore(
"part",
props.messageID,

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { resumeStreamAfterPageShow } from "./server-sdk"
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
@ -12,3 +13,41 @@ describe("resumeStreamAfterPageShow", () => {
expect(starts).toBe(1)
})
})
describe("coalesceServerEvents", () => {
const delta = (value: string, field = "text") => ({
directory: "/repo",
payload: {
type: "message.part.delta",
properties: { messageID: "msg", partID: "part", field, delta: value },
} as Event,
})
test("merges adjacent deltas for the same field", () => {
const result = coalesceServerEvents([delta("hello "), delta("world")])
expect(result).toHaveLength(1)
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
})
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
}
const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
expect(result.map((event) => event.payload.type)).toEqual([
"message.part.delta",
"message.part.delta",
"session.status",
"message.part.delta",
])
})
test("drops stale deltas", () => {
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
expect(result).toEqual([])
})
})

View file

@ -15,6 +15,39 @@ const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type QueuedServerEvent = { directory: string; payload: Event }
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
const output: QueuedServerEvent[] = []
const deltas = new Map<string, number>()
events.forEach((event) => {
if (stale && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
}
if (event.payload.type !== "message.part.delta") {
deltas.clear()
output.push(event)
return
}
const props = event.payload.properties
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
const index = deltas.get(id)
const existing = index === undefined ? undefined : output[index]
if (!existing || existing.payload.type !== "message.part.delta") {
deltas.set(id, output.length)
output.push({
directory: event.directory,
payload: { ...event.payload, properties: { ...props } },
})
return
}
existing.payload.properties.delta += props.delta
})
return output
}
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return
@ -45,7 +78,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
@ -57,8 +90,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
@ -83,14 +114,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
staleDeltas.clear()
last = Date.now()
const output = coalesceServerEvents(events, skip)
batch(() => {
for (const event of events) {
if (skip && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
}
emitter.emit(event.directory, event.payload)
}
output.forEach((event) => emitter.emit(event.directory, event.payload))
})
buffer.length = 0

View file

@ -2366,7 +2366,7 @@ export default function Layout(props: ParentProps) {
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
<HelpButton />
<ToastRegion v2={newDesign()} />
</div>
@ -2519,7 +2519,7 @@ export default function Layout(props: ParentProps) {
</div>
</div>
</div>
{import.meta.env.DEV && <DebugBar />}
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div>
<HelpButton />
<ToastRegion v2={newDesign()} />

View file

@ -13,7 +13,6 @@ import {
on,
onMount,
untrack,
createResource,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
@ -33,7 +32,6 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
@ -54,7 +52,8 @@ import {
shouldFocusTerminalOnKeyDown,
shouldShowFileTree,
} from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
import { createTimelineModel } from "@/pages/session/timeline/model"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
@ -67,11 +66,9 @@ import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
const emptyUserMessages: UserMessage[] = []
type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = []
@ -79,110 +76,6 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
loaded: () => number
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
userScrolled: () => boolean
scroller: () => HTMLDivElement | undefined
}
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
const historyScrollThreshold = 200
let shiftFrame: number | undefined
const [state, setState] = createStore({
shift: false,
})
const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
equals: same,
})
const cancelShiftReset = () => {
if (shiftFrame === undefined) return
cancelAnimationFrame(shiftFrame)
shiftFrame = undefined
}
const scheduleShiftReset = () => {
cancelShiftReset()
shiftFrame = requestAnimationFrame(() => {
shiftFrame = undefined
setState("shift", false)
})
}
const fetchOlderMessages = async () => {
const id = input.sessionID()
if (!id) return
if (!input.historyMore() || input.historyLoading()) return
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
const beforeVisible = input.visibleUserMessages().length
let loaded = input.loaded()
let growth = 0
cancelShiftReset()
setState("shift", true)
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
loaded = nextLoaded
growth = input.visibleUserMessages().length - beforeVisible
if (growth > 0) break
if (raw <= 0) break
if (!input.historyMore()) break
}
if (growth > 0) {
scheduleShiftReset()
return
}
setState("shift", false)
}
const loadAndReveal = () => fetchOlderMessages()
const onScrollerScroll = () => {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollTop >= historyScrollThreshold) return
void fetchOlderMessages()
}
createEffect(
on(
input.sessionID,
() => {
cancelShiftReset()
setState({ shift: false })
},
{ defer: true },
),
)
onCleanup(cancelShiftReset)
return {
userMessages,
shift: () => state.shift,
loadAndReveal,
onScrollerScroll,
}
}
export default function Page() {
const serverSync = useServerSync()
const layout = useLayout()
@ -323,39 +216,15 @@ export default function Page() {
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const messagesReady = createMemo(() => {
const id = params.id
if (!id) return true
return sync().data.message[id] !== undefined
})
const historyMore = createMemo(() => {
const id = params.id
if (!id) return false
return sync().session.history.more(id)
})
const historyLoading = createMemo(() => {
const id = params.id
if (!id) return false
return sync().session.history.loading(id)
})
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{
equals: same,
},
)
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID })
const historyLoading = timeline.history.loading
const historyMore = timeline.history.more
const lastUserMessage = timeline.lastUserMessage
const messages = timeline.messages
const messagesReady = timeline.ready
const sessionSync = timeline.resource
const userMessages = timeline.userMessages
const visibleUserMessages = timeline.visibleUserMessages
createEffect(() => {
const tab = activeFileTab()
@ -423,8 +292,6 @@ export default function Page() {
}, sessionKey())
let reviewFrame: number | undefined
let refreshFrame: number | undefined
let refreshTimer: number | undefined
let todoFrame: number | undefined
let todoTimer: number | undefined
let diffFrame: number | undefined
@ -614,6 +481,7 @@ export default function Page() {
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let revealMessage = (_id: string) => {}
let scrollToEnd = () => {}
let scrollMark = 0
let messageMark = 0
@ -632,39 +500,6 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
const [sessionSync] = createResource(
() => [sdk().directory, params.id] as const,
([directory, id]) => {
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
refreshFrame = undefined
refreshTimer = undefined
if (!id) return
const cached = untrack(() => sync().data.message[id] !== undefined)
const stale = !cached
? false
: (() => {
const info = getSessionPrefetch(serverSDK().scope, directory, id)
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
refreshFrame = requestAnimationFrame(() => {
refreshFrame = undefined
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
if (params.id !== id) return
untrack(() => {
if (stale) void sync().session.sync(id, { force: true })
})
}, 0)
})
return sync().session.sync(id)
},
)
createEffect(
on(
() => {
@ -1202,8 +1037,18 @@ export default function Page() {
const autoScroll = createAutoScroll({
working: () => true,
overflowAnchor: "dynamic",
overflowAnchor: "none",
})
createEffect(
on(
() => params.id,
(id, previous) => {
if (!id || !previous || id === previous) return
if (location.hash || store.messageId || ui.pendingMessage) return
autoScroll.resume()
},
),
)
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
@ -1239,7 +1084,8 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
autoScroll.forceScrollToBottom()
autoScroll.resume()
scrollToEnd()
clearMessageHash()
const el = scroller
@ -1282,16 +1128,14 @@ export default function Page() {
},
)
const historyLoader = createSessionHistoryLoader({
sessionID: () => params.id,
loaded: () => messages().length,
visibleUserMessages,
historyMore,
historyLoading,
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
userScrolled: autoScroll.userScrolled,
scroller: () => scroller,
})
let captureHistoryAnchor = () => {}
let restoreHistoryAnchor = (_done: boolean) => {}
const loadOlder = () =>
timeline.history.loadOlder({ before: () => captureHistoryAnchor(), after: restoreHistoryAnchor })
const onHistoryScroll = () => {
if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200) return
void loadOlder()
}
fill = () => {
if (fillFrame !== undefined) return
@ -1307,7 +1151,7 @@ export default function Page() {
if (el.scrollHeight > el.clientHeight + 1) return
if (!historyMore()) return
void historyLoader.loadAndReveal()
void loadOlder()
})
}
@ -1615,7 +1459,7 @@ export default function Page() {
dockHeight = next
if (stick) autoScroll.forceScrollToBottom()
if (stick) scrollToEnd()
if (el) scheduleScrollState(el)
fill()
@ -1634,7 +1478,13 @@ export default function Page() {
pendingMessage: () => ui.pendingMessage,
setPendingMessage: (value) => setUi("pendingMessage", value),
setActiveMessage,
autoScroll,
autoScroll: {
pause: autoScroll.pause,
forceScrollToBottom: () => {
autoScroll.resume()
scrollToEnd()
},
},
scroller: () => scroller,
anchor,
revealMessage: (id) => revealMessage(id),
@ -1657,8 +1507,6 @@ export default function Page() {
onCleanup(() => {
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
@ -1791,37 +1639,45 @@ export default function Page() {
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
<Show when={messagesReady() ? params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={onHistoryScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
historyShift={historyLoader.shift()}
userMessages={historyLoader.userMessages()}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/>
const root = scroller
if (root) scheduleScrollState(root)
}}
userMessages={visibleUserMessages()}
setHistoryAnchor={(handlers) => {
captureHistoryAnchor = handlers.capture
restoreHistoryAnchor = handlers.restore
}}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
setScrollToEnd={(fn) => {
scrollToEnd = fn
}}
/>
)}
</Show>
</Match>
<Match when={true}>

View file

@ -0,0 +1,30 @@
import { expect, test } from "bun:test"
import { scheduleConnectedMeasure } from "./measure"
test("does not measure an element detached before the frame", async () => {
const element = document.createElement("div")
document.body.append(element)
let calls = 0
scheduleConnectedMeasure(element, () => {
calls += 1
})
element.remove()
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
expect(calls).toBe(0)
})
test("measures a connected element on the next frame", async () => {
const element = document.createElement("div")
document.body.append(element)
let calls = 0
scheduleConnectedMeasure(element, () => {
calls += 1
})
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
expect(calls).toBe(1)
element.remove()
})

View file

@ -0,0 +1,5 @@
export function scheduleConnectedMeasure<T extends HTMLElement>(element: T, measure: (element: T) => void) {
return requestAnimationFrame(() => {
if (element.isConnected) measure(element)
})
}

View file

@ -6,8 +6,8 @@ import {
Index,
on,
onCleanup,
onMount,
Show,
mapArray,
type Accessor,
type JSX,
} from "solid-js"
@ -15,7 +15,7 @@ import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { Accordion } from "@opencode-ai/ui/accordion"
import { Button } from "@opencode-ai/ui/button"
import { Card } from "@opencode-ai/ui/card"
@ -49,7 +49,6 @@ import type {
UserMessage,
} from "@opencode-ai/sdk/v2"
import { showToast } from "@/utils/toast"
import { Binary } from "@opencode-ai/core/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/ui/session-diff"
@ -69,7 +68,9 @@ import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { makeTimer } from "@solid-primitives/timer"
import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data"
import { scheduleConnectedMeasure } from "./measure"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
@ -77,43 +78,14 @@ const emptyTools: ToolPart[] = []
const emptyAssistantMessages: AssistantMessage[] = []
const idle = { type: "idle" as const }
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "BottomSpacer" }>
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "TurnGap" }>
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((key, index) => key === b[index])
}
const timelineCacheLimit = 16
const timelineFallbackItemSize = 60
const timelineCache = new Map<string, { keys: readonly string[]; cache: VirtualizerHandle["cache"] }>()
function readTimelineCache(id: string, keys: readonly string[]) {
const entry = timelineCache.get(id)
if (!entry) return
if (sameKeys(entry.keys, keys)) return entry.cache
timelineCache.delete(id)
}
function writeTimelineCache(id: string, keys: readonly string[], handle: VirtualizerHandle | undefined) {
if (!handle || keys.length === 0) return
timelineCache.delete(id)
timelineCache.set(id, { keys: keys.slice(), cache: handle.cache })
while (timelineCache.size > timelineCacheLimit) timelineCache.delete(timelineCache.keys().next().value!)
}
function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
return rows.map((row) => {
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
})
}
const timelineCache = new Map<
string,
{ measurements: VirtualItem[]; toolOpen: Record<string, boolean | undefined> }
>()
const taskDescription = (part: PartType, sessionID: string) => {
if (part.type !== "tool" || part.tool !== "task") return
@ -278,10 +250,11 @@ export function MessageTimeline(props: {
shouldAnchorBottom: () => boolean
centered: boolean
setContentRef: (el: HTMLDivElement) => void
historyShift: boolean
userMessages: UserMessage[]
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
setHistoryAnchor?: (handlers: { capture: () => void; restore: (done: boolean) => void }) => void
}) {
let touchGesture: number | undefined
@ -293,40 +266,21 @@ export function MessageTimeline(props: {
const dialog = useDialog()
const language = useLanguage()
const { params, sessionKey } = useSessionKey()
const ownerSessionKey = sessionKey()
const cached = timelineCache.get(ownerSessionKey)
const initialMeasurements = cached?.measurements
const coldBottomMount = !initialMeasurements?.length && props.shouldAnchorBottom()
const platform = usePlatform()
let virtualizer: VirtualizerHandle | undefined
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
const id = sessionID()
if (!id) return emptyMessages
return sync().data.message[id] ?? emptyMessages
})
const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
const result = new Map<string, AssistantMessage[]>()
for (const message of sessionMessages()) {
if (message.role !== "assistant") continue
const messages = result.get(message.parentID)
if (messages) {
messages.push(message)
continue
}
result.set(message.parentID, [message])
}
return result
})
const pending = createMemo(() =>
sessionMessages().findLast(
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
const sessionStatus = createMemo(() => {
const id = sessionID()
if (!id) return idle
return sync().data.session_status[id] ?? idle
})
const working = createMemo(() => sessionStatus().type !== "idle")
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent))
const [timeoutDone, setTimeoutDone] = createSignal(true)
@ -344,25 +298,6 @@ export function MessageTimeline(props: {
makeTimer(() => setTimeoutDone(true), 260, setTimeout)
})
const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID
if (parentID) {
const messages = sessionMessages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (message && message.role === "user") return message.id
}
const status = sessionStatus()
if (status.type !== "idle") {
const messages = sessionMessages()
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].id
}
}
return undefined
})
const info = createMemo(() => {
const id = sessionID()
if (!id) return
@ -385,6 +320,7 @@ export function MessageTimeline(props: {
})
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const childTaskDescription = createMemo(() => {
const id = sessionID()
if (!id) return
@ -401,147 +337,217 @@ export function MessageTimeline(props: {
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
parts: getMsgParts,
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
})
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
const lastAssistantGroupKey = projection.lastAssistantGroupKey
const messageByID = projection.messageByID
const messageLastRowIndex = projection.messageLastRowIndex
const messageRowIndex = projection.messageRowIndex
const timelineRowByKey = projection.rowByKey
const timelineRows = projection.rows
const messageRowMemos = createMemo(
mapArray(
() => props.userMessages,
(userMessage, indexAccessor) => {
return createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
const rows = Timeline.constructMessageRows(
userMessage,
getMsgParts,
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
indexAccessor(),
settings.general.showReasoningSummaries(),
sessionStatus().type,
activeMessageID() === userMessage.id,
)
let prependAnchor: { key: string; offset: number } | undefined
let prependAnchorFrame: number | undefined
let prependLoading = false
const clearPrependAnchor = () => {
prependLoading = false
prependAnchor = undefined
if (prependAnchorFrame === undefined) return
cancelAnimationFrame(prependAnchorFrame)
prependAnchorFrame = undefined
}
const capturePrependAnchor = () => {
prependLoading = true
updatePrependAnchor()
}
const updatePrependAnchor = () => {
const root = listRoot()
if (!root) return
const view = root.getBoundingClientRect()
const anchor = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
.map((element) => ({ element, rect: element.getBoundingClientRect() }))
.filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
.sort((a, b) => a.rect.top - b.rect.top)[0]
if (!anchor) return
if (!anchor.element.dataset.timelineKey) return
prependAnchor = { key: anchor.element.dataset.timelineKey, offset: anchor.rect.top - view.top }
}
const restorePrependAnchor = (done: boolean) => {
if (done) prependLoading = false
applyPrependAnchor()
}
const applyPrependAnchor = () => {
const root = listRoot()
if (!root || !prependAnchor) return
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
let frames = 0
let stable = 0
const apply = () => {
prependAnchorFrame = undefined
const anchor = prependAnchor
if (!anchor) return
const element = root.querySelector<HTMLElement>(`[data-timeline-key="${CSS.escape(anchor.key)}"]`)
const delta = element
? element.getBoundingClientRect().top - root.getBoundingClientRect().top - anchor.offset
: undefined
if (delta !== undefined && Math.abs(delta) > 0.5) {
root.scrollTop += delta
stable = 0
} else {
stable += 1
}
frames += 1
if (stable >= 30 || frames >= 180) {
if (!prependLoading) prependAnchor = undefined
return
}
prependAnchorFrame = requestAnimationFrame(apply)
}
prependAnchorFrame = requestAnimationFrame(apply)
}
return reuseTimelineRows(previous, rows)
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
let resizePinnedIndexes: number[] = []
let resizePinFrame: number | undefined
let virtualContent: HTMLDivElement | undefined
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
get count() {
return timelineRows().length
},
getScrollElement: () => listRoot() ?? null,
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => timelineFallbackItemSize,
scrollToFn: (offset, options, instance) => {
// Expose the computed range before core writes an anchor correction so the browser does not clamp it to the old height.
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
},
get getItemKey() {
const rows = timelineRows()
return (index: number) => {
const row = rows[index]
// ResizeObserver can report a removed element after its row has left the projection.
if (!row) return `removed:${index}`
return TimelineRow.key(row)
}
},
anchorTo: "end",
followOnAppend: true,
scrollEndThreshold: 80,
get scrollMargin() {
return showHeader() ? 64 : 0
},
overscan: 50,
paddingEnd: 64,
rangeExtractor: (range) => {
const id = activeMessageID()
const active = id ? (messageLastRowIndex().get(id) ?? -1) : -1
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
return [...new Set([...resizePinnedIndexes, ...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b)
},
})
const resizeItem = virtualizer.resizeItem
virtualizer.resizeItem = (index, size) => {
const item = virtualizer.measurementsCache[index]
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
const root = listRoot()
if (root && previous !== undefined && Math.abs(size - previous) > root.clientHeight) {
const view = root.getBoundingClientRect()
resizePinnedIndexes = [...root.querySelectorAll<HTMLElement>("[data-index]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
},
),
.map((element) => Number(element.dataset.index))
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = requestAnimationFrame(() => {
resizePinFrame = undefined
resizePinnedIndexes = []
})
})
}
resizeItem(index, size)
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
item.end <= instance.getLogicalScrollOffset()
const virtualItemByKey = createMemo(
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
)
const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
const rows = messageRowMemos().flatMap((memo) => memo())
if (rows.length === 0) return rows
return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
})
const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys })
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
const messageRowIndex = createMemo(() => {
const result = new Map<string, number>()
timelineRows().forEach((row, index) => {
if (!("userMessageID" in row)) return
if (result.has(row.userMessageID)) return
result.set(row.userMessageID, index)
})
return result
})
const lastAssistantGroupKey = createMemo(() => {
const result = new Map<string, string>()
timelineRows().forEach((row) => {
if (row._tag !== "AssistantPart") return
result.set(row.userMessageID, row.group.key)
})
return result
})
const keepMounted = createMemo(() => {
const id = activeMessageID()
if (!id) return
const rows = timelineRows()
const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id)
if (index < 0) return
return [index]
})
const activeAssistantMessages = createMemo(() => {
const id = activeMessageID() ?? props.userMessages[props.userMessages.length - 1]?.id
if (!id) return emptyAssistantMessages
return assistantMessagesByParent().get(id) ?? emptyAssistantMessages
})
const activeAssistantContentVersion = createMemo(() =>
activeAssistantMessages()
.flatMap((message) => [
`${message.id}:${message.time.completed ?? ""}:${message.error?.name ?? ""}`,
...getMsgParts(message.id).map((part) => {
if (part.type === "text" || part.type === "reasoning") return `${part.id}:${part.type}:${part.text.length}`
if (part.type === "tool") {
const metadata = "metadata" in part.state ? part.state.metadata : undefined
const output =
"output" in part.state && typeof part.state.output === "string" ? part.state.output.length : 0
const metadataOutput =
metadata && typeof metadata === "object" && "output" in metadata && typeof metadata.output === "string"
? metadata.output.length
: 0
return `${part.id}:${part.tool}:${part.state.status}:${output}:${metadataOutput}`
}
return `${part.id}:${part.type}`
}),
])
.join("|"),
)
createEffect(
on(
() => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const,
() => {
if (!virtualizer) return
if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return
const keys = timelineRowKeys()
if (keys.length === 0) return
virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
scheduleMeasuredBottomAnchor()
},
{ defer: true },
),
)
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key as string))
createEffect(() => {
props.setRevealMessage?.((id) => {
const index = messageRowIndex().get(id)
if (index === undefined) return
virtualizer?.scrollToIndex(index, { align: "center" })
virtualizer.scrollToIndex(index, { align: "center" })
})
props.setScrollToEnd?.(() => virtualizer.scrollToEnd())
props.setHistoryAnchor?.({ capture: capturePrependAnchor, restore: restorePrependAnchor })
})
let overscanFrame: number | undefined
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
if (props.shouldAnchorBottom()) virtualizer.scrollToEnd()
})
})
})
let cacheSessionKey = sessionKey()
let cacheRowKeys = timelineRowKeys()
let virtualizerSessionKey = cacheSessionKey
let virtualizerRowKeys = cacheRowKeys
let bottomAnchorSessionKey = ""
let bottomAnchorFrame: number | undefined
const maybeAnchorBottom = () => {
const key = sessionKey()
if (bottomAnchorSessionKey === key) return
if (!virtualizer) return
const keys = timelineRowKeys()
if (keys.length === 0) return
if (timelineRows().length === 0) return
bottomAnchorSessionKey = key
if (!props.shouldAnchorBottom()) return
virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
bottomAnchorFrame = requestAnimationFrame(() => {
bottomAnchorFrame = undefined
if (sessionKey() !== key) return
virtualizer.scrollToEnd()
})
}
createEffect(
on(
() => [sessionKey(), timelineRowKeys()] as const,
(next, prev) => {
if (prev && prev[0] !== next[0]) writeTimelineCache(prev[0], prev[1], virtualizer)
cacheSessionKey = next[0]
cacheRowKeys = next[1]
if (virtualizer) {
virtualizerSessionKey = cacheSessionKey
virtualizerRowKeys = cacheRowKeys
maybeAnchorBottom()
}
},
{ defer: true },
),
)
let measuredSessionKey = sessionKey()
createEffect(() => {
const key = sessionKey()
timelineRows().length
if (measuredSessionKey !== key) {
measuredSessionKey = key
virtualizer.measure()
}
maybeAnchorBottom()
})
onCleanup(() => {
writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
clearPrependAnchor()
timelineCache.delete(ownerSessionKey)
timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!)
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
props.setRevealMessage?.(() => {})
props.setScrollToEnd?.(() => {})
props.setHistoryAnchor?.({ capture: () => {}, restore: () => {} })
})
const [title, setTitle] = createStore({
@ -560,17 +566,8 @@ export function MessageTimeline(props: {
const [bar, setBar] = createStore({
ms: pace(640),
})
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
let listRoot: HTMLDivElement | undefined
let listFrame: number | undefined
let contentFrame: number | undefined
let bottomAnchorFrame: number | undefined
let bottomAnchorFrames = 0
let measuredBottomAnchored = true
const [scrollRoot, setScrollRoot] = createSignal<HTMLDivElement>()
const updateTitleMetrics = () => {
if (!head || head.clientWidth <= 0) return
@ -579,83 +576,14 @@ export function MessageTimeline(props: {
createResizeObserver(() => head, updateTitleMetrics)
const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
const measureTimeline = () => {
virtualizer?.measure()
anchorMeasuredBottom()
}
function anchorMeasuredBottom() {
if (!listRoot) return false
if (!measuredBottomAnchored) return false
listRoot.scrollTop = listRoot.scrollHeight
return true
}
function scheduleMeasuredBottomAnchor() {
// Workaround for virtua issue #301: virtua does not expose a synchronous item-resize hook for
// "stay at bottom if already at bottom". Tool rows can briefly outgrow the measured virtual
// height, so keep the scroll container bottom-locked for a few frames while measurement settles.
bottomAnchorFrames = 90
if (bottomAnchorFrame !== undefined) return
const tick = () => {
bottomAnchorFrame = undefined
if (!anchorMeasuredBottom()) {
bottomAnchorFrames = 0
return
}
bottomAnchorFrames = working() ? 12 : bottomAnchorFrames - 1
if (bottomAnchorFrames <= 0) return
bottomAnchorFrame = requestAnimationFrame(tick)
}
bottomAnchorFrame = requestAnimationFrame(tick)
}
const bindContentRoot = (root: HTMLDivElement) => {
const child = root.firstElementChild
props.setContentRef(child instanceof HTMLDivElement ? child : root)
}
const scheduleContentRoot = (root: HTMLDivElement) => {
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
contentFrame = requestAnimationFrame(() => {
contentFrame = undefined
if (listRoot !== root) return
bindContentRoot(root)
})
}
const connectListRoot = (root: HTMLDivElement) => {
if (listRoot !== root) return
if (!root.isConnected || !root.ownerDocument.defaultView) {
listFrame = requestAnimationFrame(() => {
listFrame = undefined
connectListRoot(root)
})
return
}
props.setScrollRef(root)
measuredBottomAnchored = isMeasuredBottom(root)
setScrollRoot(root)
scheduleContentRoot(root)
}
const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot) return
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
listRoot = root
setScrollRoot(undefined)
connectListRoot(root)
if (root === listRoot()) return
setListRoot(root)
props.setScrollRef(root)
}
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
if (!prependLoading) clearPrependAnchor()
const root = event.currentTarget
const delta = normalizeWheelDelta({
deltaY: event.deltaY,
@ -667,6 +595,7 @@ export function MessageTimeline(props: {
}
const handleListTouchStart = (event: TouchEvent) => {
if (!prependLoading) clearPrependAnchor()
touchGesture = event.touches[0]?.clientY
}
@ -692,12 +621,13 @@ export function MessageTimeline(props: {
}
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
if (!prependLoading) clearPrependAnchor()
if (event.target !== event.currentTarget) return
props.onMarkScrollGesture(event.currentTarget)
}
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
measuredBottomAnchored = isMeasuredBottom(event.currentTarget)
if (prependLoading) updatePrependAnchor()
props.onScheduleScrollState(event.currentTarget)
props.onHistoryScroll()
if (!props.hasScrollGesture()) return
@ -707,10 +637,6 @@ export function MessageTimeline(props: {
}
onCleanup(() => {
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
setScrollRoot(undefined)
props.setScrollRef(undefined)
})
@ -1010,9 +936,7 @@ export function MessageTimeline(props: {
}
}
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>, onSizeChange?: () => void) => {
if (row().group.type === "context") {
const parts = createMemo(() => {
const group = row().group
@ -1028,7 +952,7 @@ export function MessageTimeline(props: {
busy={
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
}
onSizeChange={measureTimeline}
onSizeChange={onSizeChange}
/>
)
}
@ -1062,8 +986,9 @@ export function MessageTimeline(props: {
defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)}
deferToolContent={false}
deferToolContent
virtualizeDiff={false}
onContentRendered={onSizeChange}
/>
)}
</Show>
@ -1077,10 +1002,6 @@ export function MessageTimeline(props: {
const row = input.row()
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
}
const previousUserMessage = () => {
const row = input.row()
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
}
const previousAssistantPart = () => {
const row = input.row()
return row._tag === "AssistantPart" && row.previousAssistantPart
@ -1095,7 +1016,6 @@ export function MessageTimeline(props: {
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
"md:mx-auto": props.centered,
"pt-6": previousUserMessage(),
"pt-3": previousAssistantPart(),
}}
>
@ -1106,8 +1026,10 @@ export function MessageTimeline(props: {
)
}
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => {
switch (row()._tag) {
case "TurnGap":
return <div data-timeline-row="TurnGap" aria-hidden="true" class="h-6" />
case "CommentStrip": {
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
const comments = createMemo(() =>
@ -1195,7 +1117,7 @@ export function MessageTimeline(props: {
data-slot="session-turn-assistant-content"
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
>
{renderAssistantPartGroup(assistantPartRow)}
{renderAssistantPartGroup(assistantPartRow, onSizeChange)}
</div>
</div>
</TimelineRowFrame>
@ -1246,13 +1168,74 @@ export function MessageTimeline(props: {
</TimelineRowFrame>
)
}
case "BottomSpacer":
return <div data-timeline-row="bottom-spacer" aria-hidden="true" class="h-16" />
}
}
function TimelineRowView(props: { row: TimelineRow.TimelineRow }) {
return renderTimelineRow(() => props.row)
function TimelineRowView(props: { row: TimelineRow.TimelineRow; onSizeChange?: () => void }) {
return renderTimelineRow(() => props.row, props.onSizeChange)
}
function VirtualTimelineRow(props: { rowKey: string }) {
let element: HTMLDivElement
const initialItem = virtualItemByKey().get(props.rowKey)!
const initialRow = timelineRowByKey().get(props.rowKey)!
const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem)
const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow)
const asyncFile = () => {
const value = row()
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool)
}
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined
onMount(() => virtualizer.measureElement(element))
createEffect(
on(
() => item().index,
() => {
virtualizer.measureElement(element)
},
{ defer: true },
),
)
onCleanup(() => {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
})
return (
<div
data-timeline-key={props.rowKey}
style={{
position: "absolute",
top: `${item().start - (showHeader() ? 64 : 0)}px`,
left: "0",
width: "100%",
height: `${item().size}px`,
overflow: "clip",
}}
>
<div
ref={(value) => {
element = value
}}
data-index={item().index}
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
>
<TimelineRowView
row={row()}
onSizeChange={() => {
setReady(true)
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
contentMeasureFrame = scheduleConnectedMeasure(element, virtualizer.measureElement)
}}
/>
</div>
</div>
)
}
return (
@ -1581,33 +1564,28 @@ export function MessageTimeline(props: {
</div>
</div>
</Show>
<Show when={scrollRoot()}>
{(root) => (
<Virtualizer
data={timelineRows()}
cache={virtualCache()}
itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
scrollRef={root()}
shift={props.historyShift}
keepMounted={keepMounted()}
startMargin={64}
ref={(handle) => {
if (!handle) {
writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
virtualizer = undefined
return
}
virtualizer = handle
virtualizerSessionKey = cacheSessionKey
virtualizerRowKeys = cacheRowKeys
maybeAnchorBottom()
scheduleContentRoot(root())
}}
>
{(row) => <TimelineRowView row={row} />}
</Virtualizer>
)}
</Show>
<div
data-timeline-virtual-content
ref={(element) => {
virtualContent = element
props.setContentRef(element)
}}
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
width: "100%",
}}
>
<For each={virtualRowKeys()}>{(rowKey) => <VirtualTimelineRow rowKey={rowKey} />}</For>
<Show when={timelineRows().length > 0}>
<div
data-timeline-row="bottom-spacer"
aria-hidden="true"
class="h-16 absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
/>
</Show>
</div>
</ScrollView>
</div>
)

View file

@ -0,0 +1,101 @@
import { describe, expect, test } from "bun:test"
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
const user = (id: string) => ({ id, role: "user" }) as UserMessage
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
describe("timeline model", () => {
test("selects users and applies the revert boundary", () => {
const messages: Message[] = [user("msg_1"), assistant("msg_2"), user("msg_3"), user("msg_5")]
const users = selectUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_1", "msg_3", "msg_5"])
expect(selectVisibleUserMessages(users, "msg_5").map((message) => message.id)).toEqual(["msg_1", "msg_3"])
expect(selectVisibleUserMessages(users)).toBe(users)
})
test("loads pages until a visible user turn is added", async () => {
let loaded = 10
let visible = 2
let calls = 0
const anchors: Array<string | boolean> = []
await loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => loaded,
visible: () => visible,
more: () => true,
loading: () => false,
loadMore: async () => {
calls += 1
loaded += 3
if (calls === 2) visible += 1
},
before: () => anchors.push("before"),
after: (done) => anchors.push("after", done),
})
expect(calls).toBe(2)
expect(anchors).toEqual(["before", "after", false, "after", true])
})
test("stops when a page adds no raw messages", async () => {
let calls = 0
await loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {
calls += 1
},
})
expect(calls).toBe(1)
})
test("does not restore an anchor after the session changes", async () => {
let sessionID = "ses_old"
let restore = 0
await loadOlderTimeline({
sessionID: () => sessionID,
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {
sessionID = "ses_new"
},
after: () => {
restore += 1
},
})
expect(restore).toBe(0)
})
test("releases the anchor when loading history fails", async () => {
let restore = 0
await expect(
loadOlderTimeline({
sessionID: () => "ses_test",
loaded: () => 10,
visible: () => 2,
more: () => true,
loading: () => false,
loadMore: async () => {
throw new Error("history failed")
},
after: () => {
restore += 1
},
}),
).rejects.toThrow("history failed")
expect(restore).toBe(1)
})
})

View file

@ -0,0 +1,152 @@
import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { same } from "@/utils/same"
const emptyUserMessages: UserMessage[] = []
export function createTimelineModel(input: {
sessionID: Accessor<string | undefined>
revertMessageID: Accessor<string | undefined>
}) {
const sdk = useSDK()
const serverSDK = useServerSDK()
const sync = useSync()
let refreshFrame: number | undefined
let refreshTimer: number | undefined
const [resource] = createResource(
() => [sdk().directory, input.sessionID()] as const,
([directory, id]) => {
clearRefresh()
if (!id) return
const cached = untrack(() => sync().data.message[id] !== undefined)
const stale = cached
? (() => {
const info = getSessionPrefetch(serverSDK().scope, directory, id)
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
: false
refreshFrame = requestAnimationFrame(() => {
refreshFrame = undefined
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined
if (input.sessionID() !== id) return
untrack(() => {
if (stale) void sync().session.sync(id, { force: true })
})
}, 0)
})
return sync().session.sync(id)
},
)
const messages = createMemo(() => {
const id = input.sessionID()
return id ? (sync().data.message[id] ?? []) : []
})
const ready = createMemo(() => {
const id = input.sessionID()
return !id || sync().data.message[id] !== undefined
})
const userMessages = createMemo(
() => selectUserMessages(messages()),
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
return selectVisibleUserMessages(userMessages(), input.revertMessageID())
},
emptyUserMessages,
{ equals: same },
)
const more = createMemo(() => {
const id = input.sessionID()
return id ? sync().session.history.more(id) : false
})
const loading = createMemo(() => {
const id = input.sessionID()
return id ? sync().session.history.loading(id) : false
})
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
return loadOlderTimeline({
sessionID: input.sessionID,
loaded: () => messages().length,
visible: () => visibleUserMessages().length,
more,
loading,
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
before: options?.before,
after: options?.after,
})
}
onCleanup(clearRefresh)
return {
history: { loadOlder, loading, more },
lastUserMessage: createMemo(() => visibleUserMessages().at(-1)),
messages,
ready,
resource,
userMessages,
visibleUserMessages,
}
function clearRefresh() {
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
refreshFrame = undefined
refreshTimer = undefined
}
}
export function selectUserMessages(messages: Message[]) {
return messages.filter((message): message is UserMessage => message.role === "user")
}
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
if (!revertMessageID) return messages
return messages.filter((message) => message.id < revertMessageID)
}
export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined>
loaded: Accessor<number>
visible: Accessor<number>
more: Accessor<boolean>
loading: Accessor<boolean>
loadMore: (sessionID: string) => Promise<void>
before?: () => void
after?: (done: boolean) => void
}) {
const id = input.sessionID()
if (!id || !input.more() || input.loading()) return
// A history page may contain only assistant messages or user turns hidden by a revert boundary.
const beforeVisible = input.visible()
let loaded = input.loaded()
input.before?.()
while (true) {
await input.loadMore(id).catch((error) => {
if (input.sessionID() === id) input.after?.(true)
throw error
})
if (input.sessionID() !== id) return
const nextLoaded = input.loaded()
const growth = input.visible() - beforeVisible
const raw = nextLoaded - loaded
loaded = nextLoaded
const done = growth > 0 || raw <= 0 || !input.more()
input.after?.(done)
if (done) return
}
}

View file

@ -0,0 +1,113 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { Timeline, TimelineRow } from "./rows"
const emptyAssistantMessages: AssistantMessage[] = []
export function createTimelineProjection(input: {
messages: Accessor<Message[]>
userMessages: Accessor<UserMessage[]>
parts: (messageID: string) => Part[]
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
const result = new Map<string, AssistantMessage[]>()
input.messages().forEach((message) => {
if (message.role !== "assistant") return
const messages = result.get(message.parentID)
if (messages) {
messages.push(message)
return
}
result.set(message.parentID, [message])
})
return result
})
const activeMessageID = createMemo(() => {
const parentID = input.messages().findLast(
(message): message is AssistantMessage => message.role === "assistant" && typeof message.time.completed !== "number",
)?.parentID
if (parentID) {
const messages = input.messages()
const result = Binary.search(messages, parentID, (message) => message.id)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
if (message?.role === "user") return message.id
}
if (input.status().type === "idle") return
return input.messages().findLast((message) => message.role === "user")?.id
})
const messageRowMemos = createMemo(
mapArray(input.userMessages, (userMessage, indexAccessor) =>
createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(
previous,
Timeline.constructMessageRows(
userMessage,
input.parts,
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
indexAccessor(),
input.showReasoningSummaries(),
input.status().type,
activeMessageID() === userMessage.id,
),
),
),
),
)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(
previous,
messageRowMemos().flatMap((memo) => memo()),
),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
const messageRowIndex = createMemo(() => {
const result = new Map<string, number>()
rows().forEach((row, index) => {
if (!("userMessageID" in row) || result.has(row.userMessageID)) return
result.set(row.userMessageID, index)
})
return result
})
const messageLastRowIndex = createMemo(() => {
const result = new Map<string, number>()
rows().forEach((row, index) => {
if ("userMessageID" in row) result.set(row.userMessageID, index)
})
return result
})
const lastAssistantGroupKey = createMemo(() => {
const result = new Map<string, string>()
rows().forEach((row) => {
if (row._tag === "AssistantPart") result.set(row.userMessageID, row.group.key)
})
return result
})
return {
activeMessageID,
assistantMessagesByParent,
lastAssistantGroupKey,
messageByID,
messageRowIndex,
messageLastRowIndex,
rowByKey,
rows,
}
}
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
const next = rows.map((row) => {
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
})
if (previous.length === next.length && previous.every((row, index) => row === next[index])) return previous
return next
}

View file

@ -6,14 +6,13 @@ import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string }
export type TimelineRowMap = {
TurnGap: { userMessageID: string }
CommentStrip: {
userMessageID: string
previousUserMessage: boolean
}
UserMessage: {
userMessageID: string
anchor: boolean
previousUserMessage: boolean
}
TurnDivider: {
userMessageID: string
@ -28,18 +27,18 @@ export type TimelineRowMap = {
Retry: { userMessageID: string }
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
Error: { userMessageID: string; text: string }
BottomSpacer: {}
}
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
userMessageID: string
}> {}
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
userMessageID: string
previousUserMessage: boolean
}> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
anchor: boolean
previousUserMessage: boolean
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
@ -65,9 +64,9 @@ export namespace TimelineRow {
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {}
export type TimelineRow =
| TurnGap
| CommentStrip
| UserMessage
| TurnDivider
@ -76,10 +75,11 @@ export namespace TimelineRow {
| DiffSummary
| Error
| Retry
| BottomSpacer
export const key = (row: TimelineRow) => {
switch (row._tag) {
case "TurnGap":
return `turn-gap:${row.userMessageID}`
case "CommentStrip":
return `comment-strip:${row.userMessageID}`
case "UserMessage":
@ -96,8 +96,6 @@ export namespace TimelineRow {
return `error:${row.userMessageID}`
case "Retry":
return `retry:${row.userMessageID}`
case "BottomSpacer":
return "bottom-spacer"
}
}
@ -149,11 +147,12 @@ export namespace Timeline {
),
]
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id }))
if (comments.length > 0)
rows.push(
new TimelineRow.CommentStrip({
userMessageID: userMessage.id,
previousUserMessage,
}),
)
@ -161,7 +160,6 @@ export namespace Timeline {
new TimelineRow.UserMessage({
userMessageID: userMessage.id,
anchor: comments.length === 0,
previousUserMessage: comments.length === 0 && previousUserMessage,
}),
)