From 7ab318a7a5c074d84ff60675da3a05a2e88ff65a Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Tue, 12 May 2026 08:57:21 +1000
Subject: [PATCH 01/10] perf(app): virtualize session timeline rows
---
bun.lock | 4 +-
package.json | 2 +-
packages/app/src/pages/session.tsx | 235 +----
.../src/pages/session/message-timeline.tsx | 853 +++++++++++++-----
.../pages/session/use-session-hash-scroll.ts | 19 +-
packages/ui/package.json | 1 +
packages/ui/src/components/basic-tool.tsx | 77 +-
packages/ui/src/components/message-part.tsx | 30 +-
8 files changed, 768 insertions(+), 453 deletions(-)
diff --git a/bun.lock b/bun.lock
index c3758e2326..41d1a59222 100644
--- a/bun.lock
+++ b/bun.lock
@@ -737,7 +737,7 @@
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
"ulid": "3.0.1",
- "virtua": "0.42.3",
+ "virtua": "0.49.1",
"vite": "7.1.4",
"vite-plugin-solid": "2.11.10",
"zod": "4.1.8",
@@ -5005,7 +5005,7 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
- "virtua": ["virtua@0.42.3", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-5FoAKcEvh05qsUF97Yz42SWJ7bwnPExjUYHGuoxz1EUtfWtaOgXaRwnylJbDpA0QcH1rKvJ2qsGRi9MK1fpQbg=="],
+ "virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="],
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
diff --git a/package.json b/package.json
index 5faf8be920..3fa966f684 100644
--- a/package.json
+++ b/package.json
@@ -74,7 +74,7 @@
"shiki": "3.20.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
- "virtua": "0.42.3",
+ "virtua": "0.49.1",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 1345e355eb..e916cc2f0d 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -75,7 +75,6 @@ type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
- messagesReady: () => boolean
loaded: () => number
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
@@ -85,205 +84,78 @@ type SessionHistoryWindowInput = {
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
+function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
+ const historyScrollThreshold = 200
+ let shiftFrame: number | undefined
const [state, setState] = createStore({
- turnID: undefined as string | undefined,
- turnStart: 0,
- prefetchUntil: 0,
- prefetchNoGrowth: 0,
+ shift: false,
})
- 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)
- },
+ const userMessages = createMemo(
+ () => input.visibleUserMessages(),
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 cancelShiftReset = () => {
+ if (shiftFrame === undefined) return
+ cancelAnimationFrame(shiftFrame)
+ shiftFrame = undefined
+ }
+
+ const scheduleShiftReset = () => {
+ cancelShiftReset()
+ shiftFrame = requestAnimationFrame(() => {
+ shiftFrame = undefined
+ setState("shift", false)
})
}
- 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
- let loaded = input.loaded()
-
- if (start > 0) setTurnStart(0)
-
- if (!input.historyMore() || input.historyLoading()) return
-
- let afterVisible = beforeVisible
- let added = 0
-
- while (true) {
- await input.loadMore(id)
- if (input.sessionID() !== id) return
-
- afterVisible = input.visibleUserMessages().length
- const nextLoaded = input.loaded()
- const raw = nextLoaded - loaded
- added += raw
- loaded = nextLoaded
-
- if (afterVisible > beforeVisible) break
- if (raw <= 0) break
- if (!input.historyMore()) break
- }
-
- if (added <= 0) return
- if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
-
- const growth = afterVisible - beforeVisible
- if (growth <= 0) return
- if (turnStart() !== 0) return
-
- const target = Math.min(afterVisible, beforeVisible + turnBatch)
- setTurnStart(Math.max(0, afterVisible - target))
- }
-
- /** Scroll/prefetch path: fetch older history from server. */
- const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
+ const fetchOlderMessages = async () => {
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()
+ // TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
const beforeVisible = input.visibleUserMessages().length
- const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
let loaded = input.loaded()
- let added = 0
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
- added += raw
loaded = nextLoaded
growth = input.visibleUserMessages().length - beforeVisible
if (growth > 0) break
if (raw <= 0) break
- if (opts?.prefetch) break
if (!input.historyMore()) break
}
- const afterVisible = input.visibleUserMessages().length
-
- if (opts?.prefetch) {
- setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1)
- } else if (added > 0 && state.prefetchNoGrowth) {
- setState("prefetchNoGrowth", 0)
- }
-
- if (added <= 0) return
- if (growth <= 0) return
-
- if (opts?.prefetch) {
- const current = turnStart()
- preserveScroll(() => setTurnStart(current + growth))
+ if (growth > 0) {
+ scheduleShiftReset()
return
}
- if (turnStart() !== start) return
-
- const currentRendered = renderedUserMessages().length
- const base = Math.max(beforeRendered, currentRendered)
- const target = Math.min(afterVisible, base + turnBatch)
- preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
+ setState("shift", false)
}
+ const loadAndReveal = () => fetchOlderMessages()
+
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
- }
+ if (el.scrollTop >= historyScrollThreshold) return
void fetchOlderMessages()
}
@@ -292,27 +164,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
on(
input.sessionID,
() => {
- setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
+ cancelShiftReset()
+ setState({ shift: false })
},
{ defer: true },
),
)
- createEffect(
- on(
- () => [input.sessionID(), input.messagesReady()] as const,
- ([id, ready]) => {
- if (!id || !ready) return
- setTurnStart(initialTurnStart(input.visibleUserMessages().length))
- },
- { defer: true },
- ),
- )
+ onCleanup(cancelShiftReset)
return {
- turnStart,
- setTurnStart,
- renderedUserMessages,
+ userMessages,
+ shift: () => state.shift,
loadAndReveal,
onScrollerScroll,
}
@@ -737,6 +600,7 @@ export default function Page() {
let dockHeight = 0
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
+ let revealMessage = (_id: string) => {}
let scrollMark = 0
let messageMark = 0
@@ -1403,9 +1267,8 @@ export default function Page() {
},
)
- const historyWindow = createSessionHistoryWindow({
+ const historyLoader = createSessionHistoryLoader({
sessionID: () => params.id,
- messagesReady,
loaded: () => messages().length,
visibleUserMessages,
historyMore,
@@ -1427,9 +1290,9 @@ export default function Page() {
const el = scroller
if (!el) return
if (el.scrollHeight > el.clientHeight + 1) return
- if (historyWindow.turnStart() <= 0 && !historyMore()) return
+ if (!historyMore()) return
- void historyWindow.loadAndReveal()
+ void historyLoader.loadAndReveal()
})
}
@@ -1439,15 +1302,14 @@ export default function Page() {
[
params.id,
messagesReady(),
- historyWindow.turnStart(),
historyMore(),
historyLoading(),
autoScroll.userScrolled(),
visibleUserMessages().length,
] as const,
- ([id, ready, start, more, loading, scrolled]) => {
+ ([id, ready, more, loading, scrolled]) => {
if (!id || !ready || loading || scrolled) return
- if (start <= 0 && !more) return
+ if (!more) return
fill()
},
{ defer: true },
@@ -1754,15 +1616,14 @@ export default function Page() {
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
- turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage,
setPendingMessage: (value) => setUi("pendingMessage", value),
setActiveMessage,
- setTurnStart: historyWindow.setTurnStart,
autoScroll,
scroller: () => scroller,
anchor,
+ revealMessage: (id) => revealMessage(id),
scheduleScrollState,
consumePendingMessage: layout.pendingMessage.consume,
})
@@ -1836,7 +1697,7 @@ export default function Page() {
-
+
{
@@ -1868,14 +1729,12 @@ export default function Page() {
const root = scroller
if (root) scheduleScrollState(root)
}}
- turnStart={historyWindow.turnStart()}
- historyMore={historyMore()}
- historyLoading={historyLoading()}
- onLoadEarlier={() => {
- void historyWindow.loadAndReveal()
- }}
- renderedUserMessages={historyWindow.renderedUserMessages()}
+ historyShift={historyLoader.shift()}
+ userMessages={historyLoader.userMessages()}
anchor={anchor}
+ setRevealMessage={(fn) => {
+ revealMessage = fn
+ }}
/>
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 8bbaafb4e4..fd8c9123f9 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -1,8 +1,24 @@
-import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
+import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, type JSX } from "solid-js"
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 { Accordion } from "@opencode-ai/ui/accordion"
import { Button } from "@opencode-ai/ui/button"
+import { Card } from "@opencode-ai/ui/card"
+import {
+ ContextToolGroup,
+ groupParts,
+ Message,
+ MessageDivider,
+ Part as MessagePart,
+ partDefaultOpen,
+ renderable,
+ type PartGroup,
+ type UserActions,
+} from "@opencode-ai/ui/message-part"
+import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -10,14 +26,27 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
-import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { SessionRetry } from "@opencode-ai/ui/session-retry"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field"
-import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
+import { TextReveal } from "@opencode-ai/ui/text-reveal"
+import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
+import type {
+ AssistantMessage,
+ Message as MessageType,
+ Part as PartType,
+ SnapshotFileDiff,
+ TextPart,
+ ToolPart,
+ UserMessage,
+} from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/core/util/binary"
-import { getFilename } from "@opencode-ai/core/util/path"
+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"
+import { useFileComponent } from "@opencode-ai/ui/context/file"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -44,13 +73,127 @@ type MessageComment = {
}
const emptyMessages: MessageType[] = []
+const emptyParts: PartType[] = []
+const emptyAssistantMessages: AssistantMessage[] = []
const idle = { type: "idle" as const }
-type UserActions = {
- fork?: (input: { sessionID: string; messageID: string }) => Promise | void
- revert?: (input: { sessionID: string; messageID: string }) => Promise | void
+
+type SummaryDiff = SnapshotFileDiff & { file: string }
+
+type TimelineRow =
+ | { key: string; type: "comment-strip"; userMessageID: string; previousUserMessage: boolean }
+ | { key: string; type: "user-message"; userMessageID: string; anchor: boolean; previousUserMessage: boolean }
+ | { key: string; type: "turn-divider"; userMessageID: string; label: "compaction" | "interrupted" }
+ | {
+ key: string
+ type: "assistant-part"
+ userMessageID: string
+ group: PartGroup
+ previousAssistantPart: boolean
+ lastAssistantPart: boolean
+ }
+ | { key: string; type: "thinking"; userMessageID: string; reasoningHeading?: string }
+ | { key: string; type: "retry"; userMessageID: string }
+ | { key: string; type: "diff-summary"; userMessageID: string; diffs: SummaryDiff[] }
+ | { key: string; type: "error"; userMessageID: string; text: string }
+
+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 messageComments = (parts: Part[]): MessageComment[] =>
+function record(value: unknown): value is Record {
+ return !!value && typeof value === "object" && !Array.isArray(value)
+}
+
+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 cleanHeading(value: string) {
+ return value
+ .replace(/`([^`]+)`/g, "$1")
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ .replace(/[*_~]+/g, "")
+ .trim()
+}
+
+function reasoningHeading(text: string) {
+ const markdown = text.replace(/\r\n?/g, "\n")
+ const html = markdown.match(/]*>([\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 summaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
+ return typeof value.file === "string"
+}
+
+const messageComments = (parts: PartType[]): MessageComment[] =>
parts.flatMap((part) => {
if (part.type !== "text" || !(part as TextPart).synthetic) return []
const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
@@ -69,7 +212,7 @@ const messageComments = (parts: Part[]): MessageComment[] =>
]
})
-const taskDescription = (part: Part, sessionID: string) => {
+const taskDescription = (part: PartType, sessionID: string) => {
if (part.type !== "tool" || part.tool !== "task") return
const metadata = "metadata" in part.state ? part.state.metadata : undefined
if (metadata?.sessionId !== sessionID) return
@@ -110,101 +253,123 @@ const markBoundaryGesture = (input: {
}
}
-type StageConfig = {
- init: number
- batch: number
-}
+function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSummaries: boolean }) {
+ const language = useLanguage()
-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)
- setState("count", count)
- if (count >= currentTotal) {
- setState({ completedSession: sessionKey, activeSession: "" })
- frame = undefined
- return
- }
- frame = requestAnimationFrame(step)
- }
- frame = requestAnimationFrame(step)
- },
- ),
+ return (
+
+
+
+
+
+
)
+}
- const isStaging = createMemo(() => {
- const key = input.sessionKey()
- return state.activeSession === key && state.completedSession !== key
+function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
+ const language = useLanguage()
+ const fileComponent = useFileComponent()
+ const maxFiles = 10
+ const [state, setState] = createStore({
+ showAll: false,
+ expanded: [] as string[],
})
+ const showAll = () => state.showAll
+ const expanded = () => state.expanded
+ const overflow = createMemo(() => Math.max(0, props.diffs.length - maxFiles))
+ const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, maxFiles)))
- onCleanup(cancel)
- return { messages: stagedUserMessages, isStaging }
+ return (
+
+
+
+ {props.diffs.length} {language.t("ui.sessionTurn.diffs.changed")} {" "}
+ {language.t(props.diffs.length === 1 ? "ui.common.file.one" : "ui.common.file.other")}
+
+
+ 0}>
+ setState("showAll", !showAll())}>
+ {showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
+
+
+
+
+
setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
+ >
+
+ {(diff) => {
+ const view = normalize(diff)
+ const active = createMemo(() => expanded().includes(diff.file))
+ const [shown, setShown] = createSignal(false)
+
+ createEffect(
+ on(
+ active,
+ (value) => {
+ if (!value) {
+ setShown(false)
+ return
+ }
+
+ requestAnimationFrame(() => {
+ if (active()) setShown(true)
+ })
+ },
+ { defer: true },
+ ),
+ )
+
+ return (
+
+
+
+
+
+
+ {`\u202A${getDirectory(diff.file)}\u202C`}
+
+ {getFilename(diff.file)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }}
+
+
+
0}>
+ setState("showAll", true)}>
+ {language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
+
+
+
+
+ )
}
export function MessageTimeline(props: {
@@ -219,16 +384,14 @@ export function MessageTimeline(props: {
onMarkScrollGesture: (target?: EventTarget | null) => void
hasScrollGesture: () => boolean
onUserScroll: () => void
- onTurnBackfillScroll: () => void
+ onHistoryScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
centered: boolean
setContentRef: (el: HTMLDivElement) => void
- turnStart: number
- historyMore: boolean
- historyLoading: boolean
- onLoadEarlier: () => void
- renderedUserMessages: UserMessage[]
+ historyShift: boolean
+ userMessages: UserMessage[]
anchor: (id: string) => string
+ setRevealMessage?: (fn: (id: string) => void) => void
}) {
let touchGesture: number | undefined
@@ -242,13 +405,28 @@ export function MessageTimeline(props: {
const { params, sessionKey } = useSessionKey()
const platform = usePlatform()
- const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
+ const [viewport, setViewport] = createSignal()
+ let virtualizer: VirtualizerHandle | undefined
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()
+ 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",
@@ -333,12 +511,130 @@ export function MessageTimeline(props: {
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
- const stageCfg = { init: 1, batch: 3 }
- const staging = createTimelineStaging({
- sessionKey,
- turnStart: () => props.turnStart,
- messages: () => props.renderedUserMessages,
- config: stageCfg,
+
+ const timelineRows = createMemo(() => {
+ const rows: TimelineRow[] = []
+ const status = sessionStatus()
+ const active = activeMessageID()
+ const showReasoning = settings.general.showReasoningSummaries()
+
+ // TODO(session-timeline): replace this loaded-message flattening with core cursor-paged timeline parts once available.
+ for (const userMessage of props.userMessages) {
+ const userParts = sync.data.part[userMessage.id] ?? emptyParts
+ const comments = messageComments(userParts)
+ const assistantMessages = assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages
+ const compaction = userParts.find((part) => part.type === "compaction")
+ const interrupted = assistantMessages.some((message) => message.error?.name === "MessageAbortedError")
+ const error = assistantMessages.find((message) => message.error?.name !== "MessageAbortedError")?.error
+ const workingTurn = status.type !== "idle" && active === userMessage.id
+ const assistantPartRefs = assistantMessages.flatMap((message) =>
+ (sync.data.part[message.id] ?? emptyParts)
+ .filter((part) => renderable(part, showReasoning))
+ .map((part) => ({ messageID: message.id, part })),
+ )
+ const assistantGroups = groupParts(assistantPartRefs)
+ const diffs = (userMessage.summary?.diffs ?? [])
+ .reduceRight((result, diff) => {
+ if (!summaryDiff(diff)) return result
+ if (result.some((item) => item.file === diff.file)) return result
+ result.push(diff)
+ return result
+ }, [])
+ .reverse()
+ const heading = assistantMessages
+ .flatMap((message) => sync.data.part[message.id] ?? emptyParts)
+ .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
+ .find((value): value is string => !!value)
+
+ const previousUserMessage = rows.length > 0
+ if (comments.length > 0)
+ rows.push({
+ key: `comment-strip:${userMessage.id}`,
+ type: "comment-strip",
+ userMessageID: userMessage.id,
+ previousUserMessage,
+ })
+
+ rows.push({
+ key: `user-message:${userMessage.id}`,
+ type: "user-message",
+ userMessageID: userMessage.id,
+ anchor: comments.length === 0,
+ previousUserMessage: comments.length === 0 && previousUserMessage,
+ })
+
+ if (compaction || interrupted) {
+ rows.push({
+ key: `turn-divider:${userMessage.id}:${compaction ? "compaction" : "interrupted"}`,
+ type: "turn-divider",
+ userMessageID: userMessage.id,
+ label: compaction ? "compaction" : "interrupted",
+ })
+ }
+
+ assistantGroups.forEach((group, index) =>
+ rows.push({
+ key: `assistant-part:${userMessage.id}:${group.key}`,
+ type: "assistant-part",
+ userMessageID: userMessage.id,
+ group,
+ previousAssistantPart: index > 0,
+ lastAssistantPart: index === assistantGroups.length - 1,
+ }),
+ )
+
+ if (workingTurn && !error && status.type !== "retry" && (showReasoning ? assistantPartRefs.length === 0 : true)) {
+ rows.push({ key: `thinking:${userMessage.id}`, type: "thinking", userMessageID: userMessage.id, reasoningHeading: heading })
+ }
+
+ if (workingTurn && status.type === "retry") rows.push({ key: `retry:${userMessage.id}`, type: "retry", userMessageID: userMessage.id })
+
+ if (diffs.length > 0 && !workingTurn) {
+ rows.push({ key: `diff-summary:${userMessage.id}`, type: "diff-summary", userMessageID: userMessage.id, diffs })
+ }
+
+ if (error) {
+ const data = error.data?.message
+ rows.push({
+ key: `error:${userMessage.id}`,
+ type: "error",
+ userMessageID: userMessage.id,
+ text: unwrapErrorMessage(typeof data === "string" ? data : data === undefined || data === null ? "" : String(data)),
+ })
+ }
+ }
+
+ return rows
+ })
+ const timelineRowKeys = createMemo(() => timelineRows().map((row) => row.key), [] as string[], { equals: sameKeys })
+ const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [row.key, row] as const)))
+ const messageRowIndex = createMemo(() => {
+ const result = new Map()
+ timelineRows().forEach((row, index) => {
+ if (result.has(row.userMessageID)) return
+ result.set(row.userMessageID, index)
+ })
+ return result
+ })
+ const keepMounted = createMemo(() => {
+ const id = activeMessageID()
+ if (!id) return
+ const rows = timelineRows()
+ const index = rows.findLastIndex((row) => row.userMessageID === id)
+ if (index < 0) return
+ return [index]
+ })
+
+ createEffect(() => {
+ props.setRevealMessage?.((id) => {
+ const index = messageRowIndex().get(id)
+ if (index === undefined) return
+ virtualizer?.scrollToIndex(index, { align: "center" })
+ })
+ })
+
+ onCleanup(() => {
+ props.setRevealMessage?.(() => {})
})
const [title, setTitle] = createStore({
@@ -623,6 +919,209 @@ export function MessageTimeline(props: {
)
}
+ const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID
+
+ const turnDurationMs = (userMessageID: string) => {
+ const message = messageByID().get(userMessageID)
+ if (!message || message.role !== "user") return
+ const end = (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages).reduce(
+ (max, item) => {
+ const completed = item.time.completed
+ if (typeof completed !== "number") return max
+ if (max === undefined) return completed
+ return Math.max(max, completed)
+ },
+ undefined,
+ )
+ if (typeof end !== "number") return
+ if (end < message.time.created) return
+ return end - message.time.created
+ }
+
+ const assistantCopyPartID = (userMessageID: string) => {
+ if (workingTurn(userMessageID)) return null
+ const messages = assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages
+
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i]
+ if (!message) continue
+
+ const parts = sync.data.part[message.id] ?? emptyParts
+ for (let j = parts.length - 1; j >= 0; j--) {
+ const part = parts[j]
+ if (!part || part.type !== "text" || !part.text?.trim()) continue
+ return part.id
+ }
+ }
+ }
+
+ const partByRef = (messageID: string, partID: string) =>
+ (sync.data.part[messageID] ?? emptyParts).find((part) => part.id === partID)
+
+ const renderAssistantPartGroup = (row: Extract) => {
+ if (row.group.type === "context") {
+ const parts = row.group.refs
+ .map((ref) => partByRef(ref.messageID, ref.partID))
+ .filter((part): part is ToolPart => part?.type === "tool")
+
+ return
+ }
+
+ const message = messageByID().get(row.group.ref.messageID)
+ const part = partByRef(row.group.ref.messageID, row.group.ref.partID)
+ if (!message || !part) return null
+
+ return (
+
+ )
+ }
+
+ function TimelineRowFrame(input: { row: TimelineRow; children: JSX.Element }) {
+ const anchor = () => input.row.type === "comment-strip" || (input.row.type === "user-message" && input.row.anchor)
+
+ return (
+
+ )
+ }
+
+ const renderTimelineRow = (row: TimelineRow) => {
+ switch (row.type) {
+ case "comment-strip":
+ return (
+
+
+
+ )
+ case "user-message": {
+ const message = messageByID().get(row.userMessageID)
+ if (!message || message.role !== "user") return null
+ return (
+
+
+
+ )
+ }
+ case "turn-divider":
+ return (
+
+
+
+ )
+ case "assistant-part":
+ return (
+
+
+
+ {renderAssistantPartGroup(row)}
+
+
+
+ )
+ case "thinking":
+ return (
+
+
+
+
+
+ )
+ case "retry":
+ return (
+
+
+
+
+
+ )
+ case "diff-summary":
+ return (
+
+
+
+
+
+ )
+ case "error":
+ return (
+
+
+
+ {row.text}
+
+
+
+ )
+ }
+ }
+
return (
{
+ setViewport(el)
+ props.setScrollRef(el)
+ }}
onWheel={(e) => {
const root = e.currentTarget
const delta = normalizeWheelDelta({
@@ -691,7 +1192,7 @@ export function MessageTimeline(props: {
}}
onScroll={(e) => {
props.onScheduleScrollState(e.currentTarget)
- props.onTurnBackfillScroll()
+ props.onHistoryScroll()
if (!props.hasScrollGesture()) return
props.onUserScroll()
props.onAutoScrollHandleScroll()
@@ -1006,109 +1507,25 @@ export function MessageTimeline(props: {
"mt-0": !props.centered,
}}
>
- 0 || props.historyMore}>
-
-
-
+ {(key) => {
+ const row = timelineRowByKey().get(key)
+ if (!row) return null
+ return renderTimelineRow(row)
+ }}
+
+ )}
-
- {(messageID) => {
- const active = createMemo(() => activeMessageID() === messageID)
- const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
- equals: (a, b) =>
- a.length === b.length &&
- a.every(
- (c, i) =>
- c.path === b[i].path &&
- c.comment === b[i].comment &&
- c.selection?.startLine === b[i].selection?.startLine &&
- c.selection?.endLine === b[i].selection?.endLine,
- ),
- })
- const commentCount = createMemo(() => comments().length)
- return (
-
- )
- }}
-
diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts
index c582749d1c..65577abae2 100644
--- a/packages/app/src/pages/session/use-session-hash-scroll.ts
+++ b/packages/app/src/pages/session/use-session-hash-scroll.ts
@@ -11,21 +11,19 @@ export const useSessionHashScroll = (input: {
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise
- turnStart: () => number
currentMessageId: () => string | undefined
pendingMessage: () => string | undefined
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
- setTurnStart: (value: number) => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
+ revealMessage?: (id: string) => void
scheduleScrollState: (el: HTMLDivElement) => void
consumePendingMessage: (key: string) => string | undefined
}) => {
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
- const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
let clearing = false
@@ -77,6 +75,7 @@ export const useSessionHashScroll = (input: {
}
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
+ input.revealMessage?.(id)
const el = document.getElementById(input.anchor(id))
if (el) return scrollToElement(el, behavior)
if (left <= 0) return false
@@ -89,18 +88,7 @@ export const useSessionHashScroll = (input: {
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
-
- const index = messageIndex().get(message.id) ?? -1
- if (index !== -1 && index < input.turnStart()) {
- input.setTurnStart(index)
-
- queue(() => {
- seek(message.id, behavior)
- })
-
- updateHash(message.id)
- return
- }
+ input.revealMessage?.(message.id)
if (seek(message.id, behavior)) {
updateHash(message.id)
@@ -154,7 +142,6 @@ export const useSessionHashScroll = (input: {
if (!input.sessionID() || !input.messagesReady()) return
visibleUserMessages()
- input.turnStart()
let targetId = input.pendingMessage()
if (!targetId) {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 12441c8d09..9168085f77 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -6,6 +6,7 @@
"exports": {
"./package.json": "./package.json",
"./*": "./src/components/*.tsx",
+ "./session-diff": "./src/components/session-diff.ts",
"./i18n/*": "./src/i18n/*.ts",
"./pierre": "./src/pierre/index.ts",
"./pierre/*": "./src/pierre/*.ts",
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
index 27ad7c3c70..cb9f9b17fe 100644
--- a/packages/ui/src/components/basic-tool.tsx
+++ b/packages/ui/src/components/basic-tool.tsx
@@ -1,4 +1,4 @@
-import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
+import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "../context/i18n"
import { createStore } from "solid-js/store"
@@ -40,26 +40,76 @@ export interface BasicToolProps {
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
+const deferredMounts: Array<{ active: boolean; fn: () => void }> = []
+let deferredFrame: number | undefined
+
+function flushDeferredMounts() {
+ while (deferredMounts.length > 0) {
+ // Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn.
+ // Pop from the end so heavy default-open bodies near the bottom become interactive first.
+ const item = deferredMounts.pop()!
+ if (item.active) {
+ deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined
+ item.fn()
+ return
+ }
+ }
+ deferredFrame = undefined
+}
+
+function scheduleDeferredFlush() {
+ if (deferredFrame !== undefined) return
+ deferredFrame = requestAnimationFrame(() => {
+ deferredFrame = requestAnimationFrame(flushDeferredMounts)
+ })
+}
+
+function scheduleDeferredMount(fn: () => void) {
+ const item = { active: true, fn }
+ deferredMounts.push(item)
+ scheduleDeferredFlush()
+ return () => {
+ item.active = false
+ }
+}
+
+function scheduleFrameMount(fn: () => void) {
+ const frame = requestAnimationFrame(fn)
+ return () => cancelAnimationFrame(frame)
+}
export function BasicTool(props: BasicToolProps) {
const [state, setState] = createStore({
open: props.defaultOpen ?? false,
- ready: props.defaultOpen ?? false,
+ ready: !props.defer && (props.defaultOpen ?? false),
})
const open = () => state.open
const ready = () => state.ready
const pending = () => props.status === "pending" || props.status === "running"
+ const hasChildren = () => (props.defer ? "children" in props : props.children)
- let frame: number | undefined
+ let cancelReady: (() => void) | undefined
const cancel = () => {
- if (frame === undefined) return
- cancelAnimationFrame(frame)
- frame = undefined
+ cancelReady?.()
+ cancelReady = undefined
+ }
+
+ const scheduleReady = (initial = false) => {
+ cancel()
+ cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => {
+ cancelReady = undefined
+ if (!open()) return
+ setState("ready", true)
+ })
}
onCleanup(cancel)
+ onMount(() => {
+ if (props.defer && open()) scheduleReady(true)
+ })
+
createEffect(() => {
if (props.forceOpen) setState("open", true)
})
@@ -75,12 +125,7 @@ export function BasicTool(props: BasicToolProps) {
return
}
- cancel()
- frame = requestAnimationFrame(() => {
- frame = undefined
- if (!open()) return
- setState("ready", true)
- })
+ scheduleReady()
},
{ defer: true },
),
@@ -189,7 +234,7 @@ export function BasicTool(props: BasicToolProps) {
-
+
@@ -219,7 +264,7 @@ export function BasicTool(props: BasicToolProps) {
)}
-
+
- {props.children}
+ {props.children}
-
+
{props.children}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 137f689756..3f05bf9922 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -485,12 +485,12 @@ function same(a: readonly T[] | undefined, b: readonly T[] | undefined) {
return a.every((x, i) => x === b[i])
}
-type PartRef = {
+export type PartRef = {
messageID: string
partID: string
}
-type PartGroup =
+export type PartGroup =
| {
key: string
type: "part"
@@ -519,14 +519,14 @@ function sameGroup(a: PartGroup, b: PartGroup) {
return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!))
}
-function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
+export function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((item, i) => sameGroup(item, b[i]!))
}
-function groupParts(parts: { messageID: string; part: PartType }[]) {
+export function groupParts(parts: { messageID: string; part: PartType }[]) {
const result: PartGroup[] = []
let start = -1
@@ -574,7 +574,7 @@ function index(items: readonly T[]) {
return new Map(items.map((item) => [item.id, item] as const))
}
-function renderable(part: PartType, showReasoningSummaries = true) {
+export function renderable(part: PartType, showReasoningSummaries = true) {
if (part.type === "tool") {
if (HIDDEN_TOOLS.has(part.tool)) return false
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
@@ -590,7 +590,7 @@ function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
}
-function partDefaultOpen(part: PartType, shell = false, edit = false) {
+export function partDefaultOpen(part: PartType, shell = false, edit = false) {
if (part.type !== "tool") return
return toolDefaultOpen(part.tool, shell, edit)
}
@@ -903,7 +903,7 @@ export function AssistantMessageDisplay(props: {
)
}
-function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
+export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
const i18n = useI18n()
const [open, setOpen] = createSignal(false)
const pending = createMemo(
@@ -913,7 +913,13 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
const summary = createMemo(() => contextToolSummary(props.parts))
return (
-
+ part.id).join(",")}
+ >
+
0}>
@@ -1343,7 +1349,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
return (
-
+
{(error) => {
@@ -1486,7 +1492,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return (
-
+
}>
@@ -1529,7 +1535,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
return (
-
+
}>
From f59b41f1e0f771ebae41bd241a1c9ad4938f8fb8 Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Tue, 12 May 2026 09:44:01 +1000
Subject: [PATCH 02/10] perf(app): improve timeline reactivity architecture
using mapArray
---
.../src/pages/session/message-timeline.tsx | 182 +++++++++---------
1 file changed, 94 insertions(+), 88 deletions(-)
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index fd8c9123f9..19a55836ff 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, type JSX } from "solid-js"
+import { createEffect, createMemo, createSignal, For, Index, on, onCleanup, Show, mapArray, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router"
@@ -512,100 +512,106 @@ export function MessageTimeline(props: {
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
- const timelineRows = createMemo(() => {
- const rows: TimelineRow[] = []
- const status = sessionStatus()
- const active = activeMessageID()
- const showReasoning = settings.general.showReasoningSummaries()
+ const messageRowMemos = createMemo(
+ mapArray(
+ () => props.userMessages,
+ (userMessage, indexAccessor) => {
+ return createMemo(() => {
+ const rows: TimelineRow[] = []
+ const status = sessionStatus()
+ const active = activeMessageID()
+ const showReasoning = settings.general.showReasoningSummaries()
- // TODO(session-timeline): replace this loaded-message flattening with core cursor-paged timeline parts once available.
- for (const userMessage of props.userMessages) {
- const userParts = sync.data.part[userMessage.id] ?? emptyParts
- const comments = messageComments(userParts)
- const assistantMessages = assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages
- const compaction = userParts.find((part) => part.type === "compaction")
- const interrupted = assistantMessages.some((message) => message.error?.name === "MessageAbortedError")
- const error = assistantMessages.find((message) => message.error?.name !== "MessageAbortedError")?.error
- const workingTurn = status.type !== "idle" && active === userMessage.id
- const assistantPartRefs = assistantMessages.flatMap((message) =>
- (sync.data.part[message.id] ?? emptyParts)
- .filter((part) => renderable(part, showReasoning))
- .map((part) => ({ messageID: message.id, part })),
- )
- const assistantGroups = groupParts(assistantPartRefs)
- const diffs = (userMessage.summary?.diffs ?? [])
- .reduceRight
((result, diff) => {
- if (!summaryDiff(diff)) return result
- if (result.some((item) => item.file === diff.file)) return result
- result.push(diff)
- return result
- }, [])
- .reverse()
- const heading = assistantMessages
- .flatMap((message) => sync.data.part[message.id] ?? emptyParts)
- .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
- .find((value): value is string => !!value)
+ const userParts = sync.data.part[userMessage.id] ?? emptyParts
+ const comments = messageComments(userParts)
+ const assistantMessages = assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages
+ const compaction = userParts.find((part) => part.type === "compaction")
+ const interrupted = assistantMessages.some((message) => message.error?.name === "MessageAbortedError")
+ const error = assistantMessages.find((message) => message.error?.name !== "MessageAbortedError")?.error
+ const workingTurn = status.type !== "idle" && active === userMessage.id
+ const assistantPartRefs = assistantMessages.flatMap((message) =>
+ (sync.data.part[message.id] ?? emptyParts)
+ .filter((part) => renderable(part, showReasoning))
+ .map((part) => ({ messageID: message.id, part })),
+ )
+ const assistantGroups = groupParts(assistantPartRefs)
+ const diffs = (userMessage.summary?.diffs ?? [])
+ .reduceRight((result, diff) => {
+ if (!summaryDiff(diff)) return result
+ if (result.some((item) => item.file === diff.file)) return result
+ result.push(diff)
+ return result
+ }, [])
+ .reverse()
+ const heading = assistantMessages
+ .flatMap((message) => sync.data.part[message.id] ?? emptyParts)
+ .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
+ .find((value): value is string => !!value)
- const previousUserMessage = rows.length > 0
- if (comments.length > 0)
- rows.push({
- key: `comment-strip:${userMessage.id}`,
- type: "comment-strip",
- userMessageID: userMessage.id,
- previousUserMessage,
- })
+ const previousUserMessage = indexAccessor() > 0
+ if (comments.length > 0)
+ rows.push({
+ key: `comment-strip:${userMessage.id}`,
+ type: "comment-strip",
+ userMessageID: userMessage.id,
+ previousUserMessage,
+ })
- rows.push({
- key: `user-message:${userMessage.id}`,
- type: "user-message",
- userMessageID: userMessage.id,
- anchor: comments.length === 0,
- previousUserMessage: comments.length === 0 && previousUserMessage,
- })
+ rows.push({
+ key: `user-message:${userMessage.id}`,
+ type: "user-message",
+ userMessageID: userMessage.id,
+ anchor: comments.length === 0,
+ previousUserMessage: comments.length === 0 && previousUserMessage,
+ })
- if (compaction || interrupted) {
- rows.push({
- key: `turn-divider:${userMessage.id}:${compaction ? "compaction" : "interrupted"}`,
- type: "turn-divider",
- userMessageID: userMessage.id,
- label: compaction ? "compaction" : "interrupted",
+ if (compaction || interrupted) {
+ rows.push({
+ key: `turn-divider:${userMessage.id}:${compaction ? "compaction" : "interrupted"}`,
+ type: "turn-divider",
+ userMessageID: userMessage.id,
+ label: compaction ? "compaction" : "interrupted",
+ })
+ }
+
+ assistantGroups.forEach((group, index) =>
+ rows.push({
+ key: `assistant-part:${userMessage.id}:${group.key}`,
+ type: "assistant-part",
+ userMessageID: userMessage.id,
+ group,
+ previousAssistantPart: index > 0,
+ lastAssistantPart: index === assistantGroups.length - 1,
+ }),
+ )
+
+ if (workingTurn && !error && status.type !== "retry" && (showReasoning ? assistantPartRefs.length === 0 : true)) {
+ rows.push({ key: `thinking:${userMessage.id}`, type: "thinking", userMessageID: userMessage.id, reasoningHeading: heading })
+ }
+
+ if (workingTurn && status.type === "retry") rows.push({ key: `retry:${userMessage.id}`, type: "retry", userMessageID: userMessage.id })
+
+ if (diffs.length > 0 && !workingTurn) {
+ rows.push({ key: `diff-summary:${userMessage.id}`, type: "diff-summary", userMessageID: userMessage.id, diffs })
+ }
+
+ if (error) {
+ const data = error.data?.message
+ rows.push({
+ key: `error:${userMessage.id}`,
+ type: "error",
+ userMessageID: userMessage.id,
+ text: unwrapErrorMessage(typeof data === "string" ? data : data === undefined || data === null ? "" : String(data)),
+ })
+ }
+
+ return rows
})
}
+ )
+ )
- assistantGroups.forEach((group, index) =>
- rows.push({
- key: `assistant-part:${userMessage.id}:${group.key}`,
- type: "assistant-part",
- userMessageID: userMessage.id,
- group,
- previousAssistantPart: index > 0,
- lastAssistantPart: index === assistantGroups.length - 1,
- }),
- )
-
- if (workingTurn && !error && status.type !== "retry" && (showReasoning ? assistantPartRefs.length === 0 : true)) {
- rows.push({ key: `thinking:${userMessage.id}`, type: "thinking", userMessageID: userMessage.id, reasoningHeading: heading })
- }
-
- if (workingTurn && status.type === "retry") rows.push({ key: `retry:${userMessage.id}`, type: "retry", userMessageID: userMessage.id })
-
- if (diffs.length > 0 && !workingTurn) {
- rows.push({ key: `diff-summary:${userMessage.id}`, type: "diff-summary", userMessageID: userMessage.id, diffs })
- }
-
- if (error) {
- const data = error.data?.message
- rows.push({
- key: `error:${userMessage.id}`,
- type: "error",
- userMessageID: userMessage.id,
- text: unwrapErrorMessage(typeof data === "string" ? data : data === undefined || data === null ? "" : String(data)),
- })
- }
- }
-
- return rows
- })
+ const timelineRows = createMemo(() => messageRowMemos().flatMap((memo) => memo()))
const timelineRowKeys = createMemo(() => timelineRows().map((row) => row.key), [] as string[], { equals: sameKeys })
const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [row.key, row] as const)))
const messageRowIndex = createMemo(() => {
From bd14ab01742f8bff9a888cbeee66fca6fbf7f8cf Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Tue, 12 May 2026 09:54:24 +1000
Subject: [PATCH 03/10] fix(app): keep virtual timeline rows reactive
---
packages/app/src/pages/session/message-timeline.tsx | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 19a55836ff..89ca13c075 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -1128,6 +1128,12 @@ export function MessageTimeline(props: {
}
}
+ function TimelineRowView(props: { rowKey: string }) {
+ const row = createMemo(() => timelineRowByKey().get(props.rowKey))
+
+ return {(item) => renderTimelineRow(item)}
+ }
+
return (
- {(key) => {
- const row = timelineRowByKey().get(key)
- if (!row) return null
- return renderTimelineRow(row)
- }}
+ {(key) => }
)}
From dcbe29c7c6cee314799901d9daee0ce87b694e27 Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Tue, 12 May 2026 15:09:25 +1000
Subject: [PATCH 04/10] fix(app): stabilize session timeline virtualization
---
.../src/pages/session/message-timeline.tsx | 270 ++++++++++++------
1 file changed, 180 insertions(+), 90 deletions(-)
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 89ca13c075..d685cc91a9 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -3,7 +3,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 { VList, type VListHandle } from "virtua/solid"
import { Accordion } from "@opencode-ai/ui/accordion"
import { Button } from "@opencode-ai/ui/button"
import { Card } from "@opencode-ai/ui/card"
@@ -27,7 +27,6 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionRetry } from "@opencode-ai/ui/session-retry"
-import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
@@ -103,6 +102,70 @@ function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undef
return a.every((key, index) => key === b[index])
}
+function samePartGroup(a: PartGroup, b: PartGroup) {
+ if (a === b) return true
+ if (a.key !== b.key) return false
+ if (a.type !== b.type) return false
+ if (a.type === "part") {
+ if (b.type !== "part") return false
+ return a.ref.messageID === b.ref.messageID && a.ref.partID === b.ref.partID
+ }
+ if (b.type !== "context") return false
+ if (a.refs.length !== b.refs.length) return false
+ return a.refs.every((ref, index) => ref.messageID === b.refs[index]?.messageID && ref.partID === b.refs[index]?.partID)
+}
+
+function sameSummaryDiff(a: SummaryDiff, b: SummaryDiff) {
+ return a.file === b.file && a.patch === b.patch && a.additions === b.additions && a.deletions === b.deletions && a.status === b.status
+}
+
+function sameSummaryDiffs(a: readonly SummaryDiff[], b: readonly SummaryDiff[]) {
+ if (a === b) return true
+ if (a.length !== b.length) return false
+ return a.every((diff, index) => sameSummaryDiff(diff, b[index]!))
+}
+
+function sameTimelineRow(a: TimelineRow, b: TimelineRow) {
+ if (a === b) return true
+ if (a.key !== b.key) return false
+ if (a.type !== b.type) return false
+ if (a.userMessageID !== b.userMessageID) return false
+
+ switch (a.type) {
+ case "comment-strip":
+ return b.type === "comment-strip" && a.previousUserMessage === b.previousUserMessage
+ case "user-message":
+ return b.type === "user-message" && a.anchor === b.anchor && a.previousUserMessage === b.previousUserMessage
+ case "turn-divider":
+ return b.type === "turn-divider" && a.label === b.label
+ case "assistant-part":
+ return (
+ b.type === "assistant-part" &&
+ a.previousAssistantPart === b.previousAssistantPart &&
+ a.lastAssistantPart === b.lastAssistantPart &&
+ samePartGroup(a.group, b.group)
+ )
+ case "thinking":
+ return b.type === "thinking" && a.reasoningHeading === b.reasoningHeading
+ case "retry":
+ return b.type === "retry"
+ case "diff-summary":
+ return b.type === "diff-summary" && sameSummaryDiffs(a.diffs, b.diffs)
+ case "error":
+ return b.type === "error" && a.text === b.text
+ }
+}
+
+function reuseTimelineRows(previous: TimelineRow[] | undefined, rows: TimelineRow[]) {
+ if (!previous?.length) return rows
+ const byKey = new Map(previous.map((row) => [row.key, row] as const))
+ return rows.map((row) => {
+ const existing = byKey.get(row.key)
+ if (!existing) return row
+ return sameTimelineRow(existing, row) ? existing : row
+ })
+}
+
function record(value: unknown): value is Record {
return !!value && typeof value === "object" && !Array.isArray(value)
}
@@ -405,8 +468,7 @@ export function MessageTimeline(props: {
const { params, sessionKey } = useSessionKey()
const platform = usePlatform()
- const [viewport, setViewport] = createSignal()
- let virtualizer: VirtualizerHandle | undefined
+ let virtualizer: VListHandle | undefined
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
const id = sessionID()
@@ -516,7 +578,7 @@ export function MessageTimeline(props: {
mapArray(
() => props.userMessages,
(userMessage, indexAccessor) => {
- return createMemo(() => {
+ return createMemo((previous: TimelineRow[] | undefined) => {
const rows: TimelineRow[] = []
const status = sessionStatus()
const active = activeMessageID()
@@ -605,7 +667,7 @@ export function MessageTimeline(props: {
})
}
- return rows
+ return reuseTimelineRows(previous, rows)
})
}
)
@@ -662,15 +724,96 @@ export function MessageTimeline(props: {
let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
+ let listHost: HTMLDivElement | undefined
+ let listRoot: HTMLDivElement | undefined
+ let listCleanup = () => {}
+ let listFrame: number | undefined
+
+ const updateTitleMetrics = () => {
+ if (!head || head.clientWidth <= 0) return
+ setBar("ms", pace(head.clientWidth))
+ }
createResizeObserver(
() => head,
- () => {
- if (!head || head.clientWidth <= 0) return
- setBar("ms", pace(head.clientWidth))
- },
+ updateTitleMetrics,
)
+ const bindListRoot = () => {
+ const root = listHost?.firstElementChild
+ if (!(root instanceof HTMLDivElement)) return
+ if (root === listRoot) return
+
+ listCleanup()
+ listRoot = root
+ props.setScrollRef(root)
+ props.setContentRef(root.firstElementChild instanceof HTMLDivElement ? root.firstElementChild : root)
+
+ const onWheel = (event: WheelEvent) => {
+ const delta = normalizeWheelDelta({
+ deltaY: event.deltaY,
+ deltaMode: event.deltaMode,
+ rootHeight: root.clientHeight,
+ })
+ if (!delta) return
+ markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
+ }
+ const onTouchStart = (event: TouchEvent) => {
+ touchGesture = event.touches[0]?.clientY
+ }
+ const onTouchMove = (event: TouchEvent) => {
+ const next = event.touches[0]?.clientY
+ const prev = touchGesture
+ touchGesture = next
+ if (next === undefined || prev === undefined) return
+
+ const delta = prev - next
+ if (!delta) return
+
+ markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
+ }
+ const onTouchEnd = () => {
+ touchGesture = undefined
+ }
+ const onPointerDown = (event: PointerEvent) => {
+ if (event.target !== root) return
+ props.onMarkScrollGesture(root)
+ }
+ const onClick = (event: MouseEvent) => props.onAutoScrollInteraction(event)
+
+ root.addEventListener("wheel", onWheel, { passive: true })
+ root.addEventListener("touchstart", onTouchStart, { passive: true })
+ root.addEventListener("touchmove", onTouchMove, { passive: true })
+ root.addEventListener("touchend", onTouchEnd)
+ root.addEventListener("touchcancel", onTouchEnd)
+ root.addEventListener("pointerdown", onPointerDown)
+ root.addEventListener("click", onClick)
+ listCleanup = () => {
+ root.removeEventListener("wheel", onWheel)
+ root.removeEventListener("touchstart", onTouchStart)
+ root.removeEventListener("touchmove", onTouchMove)
+ root.removeEventListener("touchend", onTouchEnd)
+ root.removeEventListener("touchcancel", onTouchEnd)
+ root.removeEventListener("pointerdown", onPointerDown)
+ root.removeEventListener("click", onClick)
+ }
+ }
+
+ const bindListHost = (el: HTMLDivElement) => {
+ listHost = el
+ if (listFrame !== undefined) cancelAnimationFrame(listFrame)
+ listFrame = requestAnimationFrame(() => {
+ listFrame = undefined
+ bindListRoot()
+ })
+ }
+
+ onCleanup(() => {
+ if (listFrame !== undefined) cancelAnimationFrame(listFrame)
+ listCleanup()
+ props.setScrollRef(undefined)
+ })
+
const viewShare = () => {
const url = shareUrl()
if (!url) return
@@ -999,6 +1142,7 @@ export function MessageTimeline(props: {
classList={{
"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":
(input.row.type === "comment-strip" || input.row.type === "user-message") && input.row.previousUserMessage,
"pt-3": input.row.type === "assistant-part" && input.row.previousAssistantPart,
@@ -1162,67 +1306,18 @@ export function MessageTimeline(props: {
- {
- setViewport(el)
- props.setScrollRef(el)
- }}
- onWheel={(e) => {
- const root = e.currentTarget
- const delta = normalizeWheelDelta({
- deltaY: e.deltaY,
- deltaMode: e.deltaMode,
- rootHeight: root.clientHeight,
- })
- if (!delta) return
- markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
- }}
- onTouchStart={(e) => {
- touchGesture = e.touches[0]?.clientY
- }}
- onTouchMove={(e) => {
- const next = e.touches[0]?.clientY
- const prev = touchGesture
- touchGesture = next
- if (next === undefined || prev === undefined) return
-
- const delta = prev - next
- if (!delta) return
-
- const root = e.currentTarget
- markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
- }}
- onTouchEnd={() => {
- touchGesture = undefined
- }}
- onTouchCancel={() => {
- touchGesture = undefined
- }}
- onPointerDown={(e) => {
- if (e.target !== e.currentTarget) return
- props.onMarkScrollGesture(e.currentTarget)
- }}
- onScroll={(e) => {
- props.onScheduleScrollState(e.currentTarget)
- props.onHistoryScroll()
- if (!props.hasScrollGesture()) return
- props.onUserScroll()
- props.onAutoScrollHandleScroll()
- props.onMarkScrollGesture(e.currentTarget)
- }}
- onClick={props.onAutoScrollInteraction}
- class="relative min-w-0 w-full h-full"
+
-
-
+
{
head = el
- setBar("ms", pace(el.clientWidth))
+ updateTitleMetrics()
}}
data-session-title
classList={{
@@ -1507,36 +1602,31 @@ export function MessageTimeline(props: {
-
-
+
+ {
+ virtualizer = handle
+ }}
+ class="relative min-w-0 w-full h-full pb-16 no-scrollbar"
+ onScroll={() => {
+ const root = listRoot
+ if (!root) return
+ props.onScheduleScrollState(root)
+ props.onHistoryScroll()
+ if (!props.hasScrollGesture()) return
+ props.onUserScroll()
+ props.onAutoScrollHandleScroll()
+ props.onMarkScrollGesture(root)
}}
>
-
- {(root) => (
- {
- virtualizer = handle
- }}
- >
- {(key) => }
-
- )}
-
-
+ {(key) =>
}
+
-
+
)
From 240201b1394ed46292c9172a0bdf0cb317736661 Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Tue, 12 May 2026 15:43:48 +1000
Subject: [PATCH 05/10] fix(app): align virtual timeline scrolling
---
.../src/pages/session/message-timeline.tsx | 48 ++++++++-----------
packages/ui/src/components/message-part.tsx | 14 ++++--
2 files changed, 29 insertions(+), 33 deletions(-)
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index d685cc91a9..a458f95819 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -94,6 +94,9 @@ type TimelineRow =
| { key: string; type: "retry"; userMessageID: string }
| { key: string; type: "diff-summary"; userMessageID: string; diffs: SummaryDiff[] }
| { key: string; type: "error"; userMessageID: string; text: string }
+ | { key: string; type: "bottom-spacer" }
+
+type FramedTimelineRow = Exclude
function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) {
if (a === b) return true
@@ -129,6 +132,8 @@ function sameTimelineRow(a: TimelineRow, b: TimelineRow) {
if (a === b) return true
if (a.key !== b.key) return false
if (a.type !== b.type) return false
+ if (a.type === "bottom-spacer") return b.type === "bottom-spacer"
+ if (b.type === "bottom-spacer") return false
if (a.userMessageID !== b.userMessageID) return false
switch (a.type) {
@@ -371,25 +376,6 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
{(diff) => {
const view = normalize(diff)
- const active = createMemo(() => expanded().includes(diff.file))
- const [shown, setShown] = createSignal(false)
-
- createEffect(
- on(
- active,
- (value) => {
- if (!value) {
- setShown(false)
- return
- }
-
- requestAnimationFrame(() => {
- if (active()) setShown(true)
- })
- },
- { defer: true },
- ),
- )
return (
@@ -414,11 +400,9 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
-
-
-
-
-
+
+
+
)
@@ -673,12 +657,17 @@ export function MessageTimeline(props: {
)
)
- const timelineRows = createMemo(() => messageRowMemos().flatMap((memo) => memo()))
+ const timelineRows = createMemo((previous: TimelineRow[] | undefined) => {
+ const rows = messageRowMemos().flatMap((memo) => memo())
+ if (rows.length === 0) return rows
+ return reuseTimelineRows(previous, [...rows, { key: "bottom-spacer", type: "bottom-spacer" }])
+ })
const timelineRowKeys = createMemo(() => timelineRows().map((row) => row.key), [] as string[], { equals: sameKeys })
const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [row.key, row] as const)))
const messageRowIndex = createMemo(() => {
const result = new Map()
timelineRows().forEach((row, index) => {
+ if (!("userMessageID" in row)) return
if (result.has(row.userMessageID)) return
result.set(row.userMessageID, index)
})
@@ -688,7 +677,7 @@ export function MessageTimeline(props: {
const id = activeMessageID()
if (!id) return
const rows = timelineRows()
- const index = rows.findLastIndex((row) => row.userMessageID === id)
+ const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id)
if (index < 0) return
return [index]
})
@@ -1127,11 +1116,12 @@ export function MessageTimeline(props: {
showAssistantCopyPartID={assistantCopyPartID(row.userMessageID)}
turnDurationMs={turnDurationMs(row.userMessageID)}
defaultOpen={partDefaultOpen(part, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded())}
+ deferToolContent={false}
/>
)
}
- function TimelineRowFrame(input: { row: TimelineRow; children: JSX.Element }) {
+ function TimelineRowFrame(input: { row: FramedTimelineRow; children: JSX.Element }) {
const anchor = () => input.row.type === "comment-strip" || (input.row.type === "user-message" && input.row.anchor)
return (
@@ -1269,6 +1259,8 @@ export function MessageTimeline(props: {
)
+ case "bottom-spacer":
+ return
}
}
@@ -1611,7 +1603,7 @@ export function MessageTimeline(props: {
ref={(handle) => {
virtualizer = handle
}}
- class="relative min-w-0 w-full h-full pb-16 no-scrollbar"
+ class="relative min-w-0 w-full h-full"
onScroll={() => {
const root = listRoot
if (!root) return
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 3f05bf9922..a98b25477a 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -150,6 +150,7 @@ export interface MessagePartProps {
message: MessageType
hideDetails?: boolean
defaultOpen?: boolean
+ deferToolContent?: boolean
showAssistantCopyPartID?: string | null
turnDurationMs?: number
}
@@ -1233,6 +1234,7 @@ export function Part(props: MessagePartProps) {
message={props.message}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
+ deferToolContent={props.deferToolContent}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
/>
@@ -1249,6 +1251,7 @@ export interface ToolProps {
status?: string
hideDetails?: boolean
defaultOpen?: boolean
+ deferContent?: boolean
forceOpen?: boolean
locked?: boolean
}
@@ -1387,6 +1390,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
status={part().state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
+ deferContent={props.deferToolContent}
/>
@@ -1888,7 +1892,7 @@ ToolRegistry.register({
@@ -1960,7 +1964,7 @@ ToolRegistry.register({
@@ -2042,7 +2046,7 @@ ToolRegistry.register({
-
+
@@ -2134,7 +2138,7 @@ ToolRegistry.register({
From 12c6c0925f9c2999e5ed38cdc6c620a81cc2c152 Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Wed, 13 May 2026 10:16:03 +1000
Subject: [PATCH 06/10] fix(app): guard virtualizer scroll root
---
.../src/pages/session/message-timeline.tsx | 110 +++++++++++-------
1 file changed, 71 insertions(+), 39 deletions(-)
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index a458f95819..df6ee730cd 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -3,7 +3,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 { VList, type VListHandle } from "virtua/solid"
+import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
import { Accordion } from "@opencode-ai/ui/accordion"
import { Button } from "@opencode-ai/ui/button"
import { Card } from "@opencode-ai/ui/card"
@@ -452,7 +452,7 @@ export function MessageTimeline(props: {
const { params, sessionKey } = useSessionKey()
const platform = usePlatform()
- let virtualizer: VListHandle | undefined
+ let virtualizer: VirtualizerHandle | undefined
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
const id = sessionID()
@@ -713,10 +713,11 @@ export function MessageTimeline(props: {
let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
- let listHost: HTMLDivElement | undefined
let listRoot: HTMLDivElement | undefined
let listCleanup = () => {}
let listFrame: number | undefined
+ let contentFrame: number | undefined
+ const [scrollRoot, setScrollRoot] = createSignal()
const updateTitleMetrics = () => {
if (!head || head.clientWidth <= 0) return
@@ -728,15 +729,44 @@ export function MessageTimeline(props: {
updateTitleMetrics,
)
- const bindListRoot = () => {
- const root = listHost?.firstElementChild
- if (!(root instanceof HTMLDivElement)) return
+ 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)
+ setScrollRoot(root)
+ scheduleContentRoot(root)
+ }
+
+ const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot) return
+ if (listFrame !== undefined) cancelAnimationFrame(listFrame)
+ if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
listCleanup()
listRoot = root
- props.setScrollRef(root)
- props.setContentRef(root.firstElementChild instanceof HTMLDivElement ? root.firstElementChild : root)
+ setScrollRoot(undefined)
+ connectListRoot(root)
const onWheel = (event: WheelEvent) => {
const delta = normalizeWheelDelta({
@@ -788,18 +818,11 @@ export function MessageTimeline(props: {
}
}
- const bindListHost = (el: HTMLDivElement) => {
- listHost = el
- if (listFrame !== undefined) cancelAnimationFrame(listFrame)
- listFrame = requestAnimationFrame(() => {
- listFrame = undefined
- bindListRoot()
- })
- }
-
onCleanup(() => {
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
+ if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
listCleanup()
+ setScrollRoot(undefined)
props.setScrollRef(undefined)
})
@@ -1595,28 +1618,37 @@ export function MessageTimeline(props: {
-
-
{
- virtualizer = handle
- }}
- class="relative min-w-0 w-full h-full"
- onScroll={() => {
- const root = listRoot
- if (!root) return
- props.onScheduleScrollState(root)
- props.onHistoryScroll()
- if (!props.hasScrollGesture()) return
- props.onUserScroll()
- props.onAutoScrollHandleScroll()
- props.onMarkScrollGesture(root)
- }}
- >
- {(key) => }
-
+
+
+ {(root) => (
+ {
+ virtualizer = handle
+ scheduleContentRoot(root())
+ }}
+ onScroll={() => {
+ const root = listRoot
+ if (!root) return
+ props.onScheduleScrollState(root)
+ props.onHistoryScroll()
+ if (!props.hasScrollGesture()) return
+ props.onUserScroll()
+ props.onAutoScrollHandleScroll()
+ props.onMarkScrollGesture(root)
+ }}
+ >
+ {(key) => }
+
+ )}
+
From 861fe245c89f66226e971d07bd6e1f4a4b00681a Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Wed, 13 May 2026 10:29:26 +1000
Subject: [PATCH 07/10] fix(app): restore virtual timeline scrollbar
---
.../src/pages/session/message-timeline.tsx | 117 ++++++++----------
1 file changed, 54 insertions(+), 63 deletions(-)
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index df6ee730cd..554102be33 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -27,6 +27,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionRetry } from "@opencode-ai/ui/session-retry"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
@@ -714,7 +715,6 @@ export function MessageTimeline(props: {
let more: HTMLButtonElement | undefined
let head: HTMLDivElement | undefined
let listRoot: HTMLDivElement | undefined
- let listCleanup = () => {}
let listFrame: number | undefined
let contentFrame: number | undefined
const [scrollRoot, setScrollRoot] = createSignal()
@@ -763,65 +763,59 @@ export function MessageTimeline(props: {
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
- listCleanup()
listRoot = root
setScrollRoot(undefined)
connectListRoot(root)
+ }
- const onWheel = (event: WheelEvent) => {
- const delta = normalizeWheelDelta({
- deltaY: event.deltaY,
- deltaMode: event.deltaMode,
- rootHeight: root.clientHeight,
- })
- if (!delta) return
- markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
- }
- const onTouchStart = (event: TouchEvent) => {
- touchGesture = event.touches[0]?.clientY
- }
- const onTouchMove = (event: TouchEvent) => {
- const next = event.touches[0]?.clientY
- const prev = touchGesture
- touchGesture = next
- if (next === undefined || prev === undefined) return
+ const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
+ const root = event.currentTarget
+ const delta = normalizeWheelDelta({
+ deltaY: event.deltaY,
+ deltaMode: event.deltaMode,
+ rootHeight: root.clientHeight,
+ })
+ if (!delta) return
+ markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
+ }
- const delta = prev - next
- if (!delta) return
+ const handleListTouchStart = (event: TouchEvent) => {
+ touchGesture = event.touches[0]?.clientY
+ }
- markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
- }
- const onTouchEnd = () => {
- touchGesture = undefined
- }
- const onPointerDown = (event: PointerEvent) => {
- if (event.target !== root) return
- props.onMarkScrollGesture(root)
- }
- const onClick = (event: MouseEvent) => props.onAutoScrollInteraction(event)
+ const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
+ const next = event.touches[0]?.clientY
+ const prev = touchGesture
+ touchGesture = next
+ if (next === undefined || prev === undefined) return
- root.addEventListener("wheel", onWheel, { passive: true })
- root.addEventListener("touchstart", onTouchStart, { passive: true })
- root.addEventListener("touchmove", onTouchMove, { passive: true })
- root.addEventListener("touchend", onTouchEnd)
- root.addEventListener("touchcancel", onTouchEnd)
- root.addEventListener("pointerdown", onPointerDown)
- root.addEventListener("click", onClick)
- listCleanup = () => {
- root.removeEventListener("wheel", onWheel)
- root.removeEventListener("touchstart", onTouchStart)
- root.removeEventListener("touchmove", onTouchMove)
- root.removeEventListener("touchend", onTouchEnd)
- root.removeEventListener("touchcancel", onTouchEnd)
- root.removeEventListener("pointerdown", onPointerDown)
- root.removeEventListener("click", onClick)
- }
+ const delta = prev - next
+ if (!delta) return
+
+ markBoundaryGesture({ root: event.currentTarget, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
+ }
+
+ const handleListTouchEnd = () => {
+ touchGesture = undefined
+ }
+
+ const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
+ if (event.target !== event.currentTarget) return
+ props.onMarkScrollGesture(event.currentTarget)
+ }
+
+ const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ props.onScheduleScrollState(event.currentTarget)
+ props.onHistoryScroll()
+ if (!props.hasScrollGesture()) return
+ props.onUserScroll()
+ props.onAutoScrollHandleScroll()
+ props.onMarkScrollGesture(event.currentTarget)
}
onCleanup(() => {
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
- listCleanup()
setScrollRoot(undefined)
props.setScrollRef(undefined)
})
@@ -1618,10 +1612,17 @@ export function MessageTimeline(props: {
-
{(root) => (
@@ -1634,22 +1635,12 @@ export function MessageTimeline(props: {
virtualizer = handle
scheduleContentRoot(root())
}}
- onScroll={() => {
- const root = listRoot
- if (!root) return
- props.onScheduleScrollState(root)
- props.onHistoryScroll()
- if (!props.hasScrollGesture()) return
- props.onUserScroll()
- props.onAutoScrollHandleScroll()
- props.onMarkScrollGesture(root)
- }}
>
{(key) => }
)}
-
+
From 43234b8b0cf31b81a2dca5dbc7898b68d6211639 Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Wed, 13 May 2026 13:08:18 +1000
Subject: [PATCH 08/10] fix(app): restore virtual timeline cache
---
.../src/pages/session/message-timeline.tsx | 48 +++++++++++++++++++
1 file changed, 48 insertions(+)
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 554102be33..6c6eda46e8 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -106,6 +106,23 @@ function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undef
return a.every((key, index) => key === b[index])
}
+const timelineCacheLimit = 16
+const timelineCache = new Map
()
+
+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 samePartGroup(a: PartGroup, b: PartGroup) {
if (a === b) return true
if (a.key !== b.key) return false
@@ -664,6 +681,7 @@ export function MessageTimeline(props: {
return reuseTimelineRows(previous, [...rows, { key: "bottom-spacer", type: "bottom-spacer" }])
})
const timelineRowKeys = createMemo(() => timelineRows().map((row) => row.key), [] as string[], { equals: sameKeys })
+ const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [row.key, row] as const)))
const messageRowIndex = createMemo(() => {
const result = new Map()
@@ -691,7 +709,29 @@ export function MessageTimeline(props: {
})
})
+ let cacheSessionKey = sessionKey()
+ let cacheRowKeys = timelineRowKeys()
+ let virtualizerSessionKey = cacheSessionKey
+ let virtualizerRowKeys = cacheRowKeys
+
+ 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
+ }
+ },
+ { defer: true },
+ ),
+ )
+
onCleanup(() => {
+ writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
props.setRevealMessage?.(() => {})
})
@@ -1628,11 +1668,19 @@ export function MessageTimeline(props: {
{(root) => (
{
+ if (!handle) {
+ writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
+ virtualizer = undefined
+ return
+ }
virtualizer = handle
+ virtualizerSessionKey = cacheSessionKey
+ virtualizerRowKeys = cacheRowKeys
scheduleContentRoot(root())
}}
>
From d4db264a1a963541a9190e86bdd16f28a13ca805 Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Wed, 13 May 2026 13:43:45 +1000
Subject: [PATCH 09/10] fix(app): anchor virtual timeline to bottom
---
packages/app/src/pages/session.tsx | 6 +++++-
.../app/src/pages/session/message-timeline.tsx | 17 +++++++++++++++++
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 08fad258f6..b45ab7f9de 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { checksum } from "@opencode-ai/core/util/encode"
-import { useSearchParams } from "@solidjs/router"
+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"
@@ -196,6 +196,7 @@ export default function Page() {
const comments = useComments()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
+ const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => {
@@ -1719,6 +1720,9 @@ export default function Page() {
onUserScroll={markUserScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
+ shouldAnchorBottom={() =>
+ !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
+ }
centered={centered()}
setContentRef={(el) => {
content = el
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 6c6eda46e8..6ef62ea22b 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -107,6 +107,7 @@ function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undef
}
const timelineCacheLimit = 16
+const timelineFallbackItemSize = 60
const timelineCache = new Map()
function readTimelineCache(id: string, keys: readonly string[]) {
@@ -451,6 +452,7 @@ export function MessageTimeline(props: {
onUserScroll: () => void
onHistoryScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
+ shouldAnchorBottom: () => boolean
centered: boolean
setContentRef: (el: HTMLDivElement) => void
historyShift: boolean
@@ -713,6 +715,19 @@ export function MessageTimeline(props: {
let cacheRowKeys = timelineRowKeys()
let virtualizerSessionKey = cacheSessionKey
let virtualizerRowKeys = cacheRowKeys
+ let bottomAnchorSessionKey = ""
+
+ const maybeAnchorBottom = () => {
+ const key = sessionKey()
+ if (bottomAnchorSessionKey === key) return
+ if (!virtualizer) return
+ if (timelineRowKeys().length === 0) return
+ bottomAnchorSessionKey = key
+ if (!props.shouldAnchorBottom()) return
+ virtualizer.scrollToIndex(timelineRowKeys().length - 1, { align: "end" })
+ }
+
+ createEffect(maybeAnchorBottom)
createEffect(
on(
@@ -1669,6 +1684,7 @@ export function MessageTimeline(props: {
From 44c0ec7847963d2757001dec418eb2e1d16f10cb Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Wed, 13 May 2026 13:54:07 +1000
Subject: [PATCH 10/10] fix(app): simplify timeline bottom anchor
---
packages/app/src/pages/session/message-timeline.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 6ef62ea22b..f8134976a8 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -721,14 +721,13 @@ export function MessageTimeline(props: {
const key = sessionKey()
if (bottomAnchorSessionKey === key) return
if (!virtualizer) return
- if (timelineRowKeys().length === 0) return
+ const keys = timelineRowKeys()
+ if (keys.length === 0) return
bottomAnchorSessionKey = key
if (!props.shouldAnchorBottom()) return
- virtualizer.scrollToIndex(timelineRowKeys().length - 1, { align: "end" })
+ virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
}
- createEffect(maybeAnchorBottom)
-
createEffect(
on(
() => [sessionKey(), timelineRowKeys()] as const,
@@ -739,6 +738,7 @@ export function MessageTimeline(props: {
if (virtualizer) {
virtualizerSessionKey = cacheSessionKey
virtualizerRowKeys = cacheRowKeys
+ maybeAnchorBottom()
}
},
{ defer: true },