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 ( +
+
+ {input.children} +
+
+ ) + } + + const renderTimelineRow = (row: TimelineRow) => { + switch (row.type) { + case "comment-strip": + return ( + +
+
+
+ + {(commentAccessor: () => MessageComment) => { + const comment = createMemo(() => commentAccessor()) + return ( + + {(c) => ( +
+
+ + {getFilename(c().path)} + + {(selection) => ( + + {selection().startLine === selection().endLine + ? `:${selection().startLine}` + : `:${selection().startLine}-${selection().endLine}`} + + )} + +
+
+ {c().comment} +
+
+ )} +
+ ) + }} +
+
+
+
+
+ ) + 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 ( -
- 0}> -
-
-
- - {(commentAccessor: () => MessageComment) => { - const comment = createMemo(() => commentAccessor()) - return ( - - {(c) => ( -
-
- - {getFilename(c().path)} - - {(selection) => ( - - {selection().startLine === selection().endLine - ? `:${selection().startLine}` - : `:${selection().startLine}-${selection().endLine}`} - - )} - -
-
- {c().comment} -
-
- )} -
- ) - }} -
-
-
-
-
- -
- ) - }} -
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
-
{(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 },