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
parent e772664389
commit 3b811bd019
49 changed files with 2822 additions and 843 deletions
@@ -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()
})
@@ -0,0 +1,5 @@
export function scheduleConnectedMeasure<T extends HTMLElement>(element: T, measure: (element: T) => void) {
return requestAnimationFrame(() => {
if (element.isConnected) measure(element)
})
}
File diff suppressed because it is too large Load Diff
@@ -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)
})
})
@@ -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
}
}
@@ -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
}
@@ -0,0 +1,361 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, PartGroup, renderable } from "@opencode-ai/ui/message-part"
import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string }
export type TimelineRowMap = {
TurnGap: { userMessageID: string }
CommentStrip: {
userMessageID: string
}
UserMessage: {
userMessageID: string
anchor: boolean
}
TurnDivider: {
userMessageID: string
label: "compaction" | "interrupted"
}
AssistantPart: {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
Error: { userMessageID: string; text: string }
}
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
userMessageID: string
}> {}
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
userMessageID: string
}> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
anchor: boolean
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
label: "compaction" | "interrupted"
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
reasoningHeading?: string
}> {}
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
userMessageID: string
diffs: SummaryDiff[]
}> {}
export class Error extends Data.TaggedClass("Error")<{
userMessageID: string
text: string
}> {}
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
export type TimelineRow =
| TurnGap
| CommentStrip
| UserMessage
| TurnDivider
| AssistantPart
| Thinking
| DiffSummary
| Error
| Retry
export const key = (row: TimelineRow) => {
switch (row._tag) {
case "TurnGap":
return `turn-gap:${row.userMessageID}`
case "CommentStrip":
return `comment-strip:${row.userMessageID}`
case "UserMessage":
return `user-message:${row.userMessageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}:${row.label}`
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "DiffSummary":
return `diff-summary:${row.userMessageID}`
case "Error":
return `error:${row.userMessageID}`
case "Retry":
return `retry:${row.userMessageID}`
}
}
export function equals(a: TimelineRow, b: TimelineRow) {
return Equal.equals(a, b)
}
}
export namespace Timeline {
export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
assistantMessages: AssistantMessage[],
index: number,
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
const previousUserMessage = index > 0
const userParts = getMessageParts(userMessage.id)
const comments = userParts.flatMap((p) => MessageComment.fromPart(p) ?? [])
const compaction = userParts.some((p) => p.type === "compaction")
const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
const interrupted = interruptedMessageIndex !== -1
const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id)
.filter((part) => renderable(part, showReasoning))
.map((part) => ({ messageID: message.id, messageIndex, part })),
)
const assistantItems =
interrupted && !compaction
? [
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map(
(group) => ({
type: "part" as const,
group,
}),
),
{ type: "interrupted" as const },
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map(
(group) => ({
type: "part" as const,
group,
}),
),
]
: 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,
}),
)
rows.push(
new TimelineRow.UserMessage({
userMessageID: userMessage.id,
anchor: comments.length === 0,
}),
)
if (compaction) {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "compaction",
}),
)
}
let assistantGroupIndex = 0
assistantItems.forEach((item) => {
if (item.type === "interrupted") {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "interrupted",
}),
)
return
}
rows.push(
new TimelineRow.AssistantPart({
userMessageID: userMessage.id,
group: item.group,
previousAssistantPart: assistantGroupIndex > 0,
}),
)
assistantGroupIndex += 1
})
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
const heading = assistantMessages
.flatMap((message) => getMessageParts(message.id))
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
.find((value): value is string => !!value)
rows.push(
new TimelineRow.Thinking({
userMessageID: userMessage.id,
reasoningHeading: heading,
}),
)
}
if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id }))
const diffs = (userMessage.summary?.diffs ?? [])
.reduceRight<SummaryDiff[]>((result, diff) => {
if (!isSummaryDiff(diff)) return result
if (result.some((item) => item.file === diff.file)) return result
result.push(diff)
return result
}, [])
.reverse()
if (diffs.length > 0 && (status === "idle" || !isActive)) {
rows.push(
new TimelineRow.DiffSummary({
userMessageID: userMessage.id,
diffs,
}),
)
}
if (error) {
const data = error.data?.message
rows.push(
new TimelineRow.Error({
userMessageID: userMessage.id,
text: unwrapErrorMessage(
typeof data === "string" ? data : data === undefined || data === null ? "" : String(data),
),
}),
)
}
return rows
}
function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
return typeof value.file === "string"
}
function reasoningHeading(text: string) {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
if (html?.[1]) {
const value = cleanHeading(html[1].replace(/<[^>]+>/g, " "))
if (value) return value
}
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
if (atx?.[1]) {
const value = cleanHeading(atx[1])
if (value) return value
}
const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
if (setext?.[1]) {
const value = cleanHeading(setext[1])
if (value) return value
}
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
if (strong?.[1]) {
const value = cleanHeading(strong[1])
if (value) return value
}
}
function cleanHeading(value: string) {
return value
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[*_~]+/g, "")
.trim()
}
function unwrapErrorMessage(message: string) {
const text = message.replace(/^Error:\s*/, "").trim()
const parse = (value: string) => {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
const read = (value: string) => {
const first = parse(value)
if (typeof first !== "string") return first
return parse(first.trim())
}
let json = read(text)
if (json === undefined) {
const start = text.indexOf("{")
const end = text.lastIndexOf("}")
if (start !== -1 && end > start) json = read(text.slice(start, end + 1))
}
if (!record(json)) return message
const err = record(json.error) ? json.error : undefined
if (err) {
const type = typeof err.type === "string" ? err.type : undefined
const msg = typeof err.message === "string" ? err.message : undefined
if (type && msg) return `${type}: ${msg}`
if (msg) return msg
if (type) return type
const code = typeof err.code === "string" ? err.code : undefined
if (code) return code
}
const msg = typeof json.message === "string" ? json.message : undefined
if (msg) return msg
const reason = typeof json.error === "string" ? json.error : undefined
if (reason) return reason
return message
}
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
}
export namespace MessageComment {
export type MessageComment = {
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
}
export const fromPart = (part: Part): MessageComment | undefined => {
if (part.type !== "text" || !part.synthetic) return
const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
if (!next) return
return {
path: next.path,
comment: next.comment,
selection: next.selection
? {
startLine: next.selection.startLine,
endLine: next.selection.endLine,
}
: undefined,
}
}
}